Progressive Web Apps: A Deep Dive | Clarity Technologies
pwaservice-workersweb-app-manifestcachingofflinepush-notificationsbrowserfrontend

Progressive Web Apps: A Deep Dive

KK
Kashyap Kumar
35 min read
Progressive Web Apps: A Deep Dive

What is a Progressive Web App?

You have almost certainly used one without knowing it. Twitter, Spotify, Starbucks, Pinterest, Uber, and Google Maps all ship Progressive Web Apps. On a fast connection they feel like any other website. On a slow connection, or with no internet at all, they still work. On a phone, you can install them to your home screen and they open like a native app, full-screen, with no browser chrome.

A Progressive Web App (PWA) is a web application that uses a specific set of browser APIs to deliver an experience that feels native: installable, offline-capable, fast, and able to receive push notifications. The key word is "progressive": these features enhance the experience for users whose browsers and devices support them, without breaking the experience for anyone else.

PWAs are not a single technology. They are a combination of three core ingredients working together:

The three pillars of a Progressive Web App: Service Worker, Web App Manifest, and HTTPS

This post is a complete technical walkthrough of all three, plus the supporting APIs that make PWAs powerful. We will go from the theory to working code.


Why PWAs Exist: The Problem They Solve

Before diving in, it helps to understand what gap PWAs fill.

Native apps (iOS and Android) have genuine advantages: they work offline, they can send push notifications, they load from the home screen instantly, and they can access device hardware. But building native requires separate codebases (Swift/Kotlin), separate distribution (App Store/Play Store), separate approval processes, and significant expertise.

Web apps have their own advantages: one codebase, instant updates (no app store review), discoverable via search, linkable with a URL, and zero installation friction. But traditionally they required a network connection and could not be installed or send push notifications.

PWAs close that gap. They let you write once (HTML, CSS, JavaScript) and get most of the benefits of both worlds.

The business case is clear. When Pinterest built their PWA:

  • Time spent on site increased 40%
  • User-generated ad revenue increased 44%
  • Core engagements increased 60%

When Twitter built Twitter Lite (their PWA):

  • 65% increase in pages per session
  • 75% increase in tweets sent
  • 20% decrease in bounce rate

Pillar 1: HTTPS (The Non-Negotiable Foundation)

Before anything else: PWAs require HTTPS. Not optional.

Service workers (which we will get to) can intercept all network requests your page makes. If a malicious actor could inject a service worker over an unencrypted connection, they could hijack every request your users make. HTTPS prevents this.

In practice this is rarely a blocker. Let's Encrypt provides free TLS certificates, and most hosting platforms (Vercel, Netlify, Cloudflare Pages) provide HTTPS automatically.

During development, localhost is treated as a secure origin. You can test all PWA features locally without a certificate.


Pillar 2: The Web App Manifest

The Web App Manifest is a JSON file that tells the browser everything it needs to know to install your app: its name, icons, colors, and how it should open.

A Complete Manifest File

{
  "name": "Clarity App",
  "short_name": "Clarity",
  "description": "Design, development, and marketing that converts.",
  "start_url": "/",
  "scope": "/",
  "display": "standalone",
  "orientation": "portrait-primary",
  "background_color": "#ffffff",
  "theme_color": "#1e3a5f",
  "lang": "en-US",
  "dir": "ltr",
  "icons": [
    {
      "src": "/icons/icon-72x72.png",
      "sizes": "72x72",
      "type": "image/png",
      "purpose": "maskable any"
    },
    {
      "src": "/icons/icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "maskable any"
    },
    {
      "src": "/icons/icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "maskable any"
    }
  ],
  "screenshots": [
    {
      "src": "/screenshots/desktop.png",
      "sizes": "1280x720",
      "type": "image/png",
      "form_factor": "wide"
    },
    {
      "src": "/screenshots/mobile.png",
      "sizes": "390x844",
      "type": "image/png",
      "form_factor": "narrow"
    }
  ],
  "shortcuts": [
    {
      "name": "New Project",
      "short_name": "New",
      "description": "Start a new project",
      "url": "/projects/new",
      "icons": [{ "src": "/icons/new-project.png", "sizes": "96x96" }]
    }
  ],
  "categories": ["business", "productivity"],
  "prefer_related_applications": false
}

Link it from every HTML page in the <head>:

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

Key Manifest Fields Explained

display controls how the app opens when launched from the home screen:

display: "browser"     - opens in a regular browser tab (no PWA feel)
display: "minimal-ui"  - browser chrome, but minimal (back button, URL)
display: "standalone"  - looks like a native app (no URL bar)
display: "fullscreen"  - covers entire screen (games, media players)

For most apps, "standalone" is the right choice. It removes the browser chrome so the experience is indistinguishable from a native app.

start_url is the page that opens when the user launches the installed app. Always include a query parameter for analytics so you can measure installs: "start_url": "/?source=pwa".

scope defines which URLs are considered "inside" the app. If a user navigates outside the scope (e.g., clicks an external link), it opens in a browser tab instead.

theme_color sets the color of the browser address bar and the system status bar on Android. It should match your primary brand color.

icons need at minimum 192x192 and 512x512 PNG icons. The purpose: "maskable" variant tells the OS it can crop the icon into any shape (circle, rounded square, etc.) safely, because your content is inside a "safe zone" in the center.

The Maskable Icon Safe Zone

Android adaptive icons apply a mask to app icons. If your icon has important content at the edges, it will be clipped. The safe zone is the inner 80% of the icon:

Maskable icon safe zone diagram showing the inner 80% safe area and clipped edges

Keep your logo and important content within the safe zone. Use maskable.app to preview your icon against all Android mask shapes before shipping.


Pillar 3: The Service Worker

This is the heart of everything. A Service Worker is a JavaScript file that the browser runs in a separate background thread, completely separate from your web page. It has no access to the DOM. What it does have is the ability to intercept every network request your page makes and decide what to do with it.

The Service Worker Mental Model

Think of a service worker as a programmable network proxy sitting between your web page and the internet:

Diagram comparing request flow without and with a service worker acting as a network proxy

The service worker can:

  • Serve responses from a local cache (offline support)
  • Fetch from the network and update the cache in the background
  • Intercept and modify requests before they hit the network
  • Receive push notifications from a server (even when the page is closed)
  • Run background sync tasks when connectivity is restored

Service Worker Lifecycle

Understanding the lifecycle is critical. Many PWA bugs come from misunderstanding when a service worker takes effect.

Service worker lifecycle diagram showing the six stages: Registration, Installation, Waiting, Activation, Idle/Fetch, and Terminated

The "waiting" phase trips up many developers. You install a new service worker, refresh the page, and the new version seems to do nothing. This is by design. The old service worker is still controlling the page. Only when you close all tabs does the new one activate.

During development, use Chrome DevTools > Application > Service Workers > "Update on reload" to bypass this.

Registering a Service Worker

Registration happens from your main JavaScript file, not from the service worker itself:

// main.js (runs in the page, not in the service worker)

async function registerServiceWorker() {
  if (!('serviceWorker' in navigator)) {
    console.log('Service workers not supported');
    return;
  }

  try {
    const registration = await navigator.serviceWorker.register('/sw.js', {
      scope: '/'
    });

    registration.addEventListener('updatefound', () => {
      const newWorker = registration.installing;
      console.log('New service worker installing:', newWorker);
    });

    console.log('Service worker registered, scope:', registration.scope);
  } catch (error) {
    console.error('Service worker registration failed:', error);
  }
}

// Register after the page loads
window.addEventListener('load', registerServiceWorker);

The scope defaults to the directory containing the service worker file. A service worker at /sw.js controls all pages at /. A service worker at /blog/sw.js only controls pages under /blog/.

The Fetch Event: Intercepting Requests

The most important event in a service worker is fetch. Every network request from your page flows through it:

// sw.js

self.addEventListener('fetch', (event) => {
  // event.request is the original Request object
  // event.respondWith() lets you override the response

  event.respondWith(
    // Try the cache first, fall back to network
    caches.match(event.request).then((cachedResponse) => {
      if (cachedResponse) {
        return cachedResponse; // serve from cache
      }
      return fetch(event.request); // fall back to network
    })
  );
});

This is the simplest possible strategy: cache first, network fallback. But different resources need different strategies.


Caching Strategies

Choosing the right caching strategy for each type of resource is the most important design decision in a PWA. There is no single right answer. You mix and match strategies based on how often the content changes and how critical freshness is.

The Five Core Strategies

  1. Cache First (Cache Falling Back to Network): Check cache first. If found, serve it. If not, fetch from network, cache the response, then serve it. Best for static assets (JS, CSS, fonts, images with a content hash in the filename).
  2. Network First (Network Falling Back to Cache): Try the network first. If successful, cache the response and serve it. If the network fails, serve the stale cached version. Best for API responses and HTML pages that must be fresh.
  3. Stale While Revalidate: Serve from cache immediately (even if stale), and simultaneously fetch an updated version from the network in the background to refresh the cache for next time. Best for non-critical content where speed matters more than freshness (avatars, blog posts).
  4. Network Only: Always fetch from the network. Never cache. Best for analytics pings, POST requests, and payments.
  5. Cache Only: Serve exclusively from cache. Never touch the network. Best for assets pre-cached at install time as part of the app shell.

Strategy 1: Cache First

// sw.js
const STATIC_CACHE = 'static-v1';

self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.open(STATIC_CACHE).then(async (cache) => {
      const cached = await cache.match(event.request);
      if (cached) return cached;

      const response = await fetch(event.request);
      // Only cache successful responses
      if (response.ok) {
        cache.put(event.request, response.clone());
      }
      return response;
    })
  );
});

Notice response.clone(). A Response body can only be consumed once (it is a stream). If you cache.put() and then return the same response, the body will already be consumed and the page gets an empty response. Always clone before caching.

Strategy 2: Network First

// sw.js
const DYNAMIC_CACHE = 'dynamic-v1';

async function networkFirst(request) {
  try {
    const response = await fetch(request);
    if (response.ok) {
      const cache = await caches.open(DYNAMIC_CACHE);
      cache.put(request, response.clone());
    }
    return response;
  } catch (error) {
    // Network failed: try cache
    const cached = await caches.match(request);
    if (cached) return cached;

    // Nothing in cache either: return offline fallback
    return caches.match('/offline.html');
  }
}

self.addEventListener('fetch', (event) => {
  if (event.request.mode === 'navigate') {
    event.respondWith(networkFirst(event.request));
  }
});

Strategy 3: Stale While Revalidate

This strategy gives you the best of both worlds for most content: instant response from cache AND fresh data for next time.

// sw.js
async function staleWhileRevalidate(request) {
  const cache = await caches.open('swr-cache-v1');
  const cached = await cache.match(request);

  // Kick off a network fetch in the background regardless
  const networkFetch = fetch(request).then((response) => {
    if (response.ok) {
      cache.put(request, response.clone());
    }
    return response;
  });

  // Return cached immediately if available, otherwise wait for network
  return cached ?? networkFetch;
}

Pre-caching: The App Shell

The App Shell pattern is fundamental to PWAs. You identify the minimal set of assets needed to show a meaningful UI (your HTML skeleton, main CSS, core JS bundle, logo, fonts) and cache them at install time. The app shell should never change for a given version.

// sw.js
const APP_SHELL_CACHE = 'app-shell-v1';

// These files are pre-cached when the service worker installs
const APP_SHELL_FILES = [
  '/',
  '/offline.html',
  '/styles.a3f9b2.css',
  '/app.8d7c3e.js',
  '/icons/icon-192x192.png',
  '/fonts/fira-sans.woff2',
];

self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(APP_SHELL_CACHE).then((cache) => {
      console.log('Pre-caching app shell');
      return cache.addAll(APP_SHELL_FILES);
    })
  );

  // Skip waiting so this SW activates immediately
  self.skipWaiting();
});

event.waitUntil() keeps the service worker in the installing state until the promise resolves. If any file in addAll() fails to download, the entire installation fails and the service worker does not activate. Be conservative with which files you include here.

Cleaning Up Old Caches

When you deploy a new version (new cache name app-shell-v2), old caches from previous versions sit on the user's device eating storage. The activate event is the right place to clean them up:

// sw.js
const CURRENT_CACHES = ['app-shell-v2', 'dynamic-v1'];

self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames
          .filter((name) => !CURRENT_CACHES.includes(name))
          .map((name) => {
            console.log('Deleting old cache:', name);
            return caches.delete(name);
          })
      );
    })
  );

  // Claim all clients immediately (don't wait for tab reopen)
  self.clients.claim();
});

self.clients.claim() makes the activated service worker take control of all open tabs immediately, without waiting for a page reload.

A Complete, Production-Ready Service Worker

Here is a service worker that combines everything above, routing different request types to the appropriate strategy:

// sw.js

const APP_SHELL_CACHE = 'app-shell-v2';
const DYNAMIC_CACHE = 'dynamic-v1';
const CURRENT_CACHES = [APP_SHELL_CACHE, DYNAMIC_CACHE];

const APP_SHELL_FILES = [
  '/',
  '/offline.html',
  '/styles.abc123.css',
  '/app.def456.js',
];

// ---------- INSTALL ----------
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(APP_SHELL_CACHE).then((cache) => cache.addAll(APP_SHELL_FILES))
  );
  self.skipWaiting();
});

// ---------- ACTIVATE ----------
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((names) =>
      Promise.all(
        names
          .filter((name) => !CURRENT_CACHES.includes(name))
          .map((name) => caches.delete(name))
      )
    )
  );
  self.clients.claim();
});

// ---------- FETCH ----------
self.addEventListener('fetch', (event) => {
  const { request } = event;
  const url = new URL(request.url);

  // 1. Skip non-GET requests and cross-origin requests
  if (request.method !== 'GET' || url.origin !== location.origin) return;

  // 2. API calls: network first
  if (url.pathname.startsWith('/api/')) {
    event.respondWith(networkFirst(request));
    return;
  }

  // 3. Static assets with content hash in filename: cache first
  if (/\.[a-f0-9]{6,}\.(css|js|woff2|png|jpg)$/.test(url.pathname)) {
    event.respondWith(cacheFirst(request));
    return;
  }

  // 4. HTML navigation: stale while revalidate
  if (request.mode === 'navigate') {
    event.respondWith(staleWhileRevalidate(request));
    return;
  }

  // 5. Everything else: stale while revalidate
  event.respondWith(staleWhileRevalidate(request));
});

// ---------- STRATEGIES ----------

async function cacheFirst(request) {
  const cached = await caches.match(request);
  if (cached) return cached;
  const response = await fetch(request);
  if (response.ok) {
    const cache = await caches.open(DYNAMIC_CACHE);
    cache.put(request, response.clone());
  }
  return response;
}

async function networkFirst(request) {
  const cache = await caches.open(DYNAMIC_CACHE);
  try {
    const response = await fetch(request);
    if (response.ok) cache.put(request, response.clone());
    return response;
  } catch {
    const cached = await cache.match(request);
    return cached ?? new Response(JSON.stringify({ error: 'Offline' }), {
      headers: { 'Content-Type': 'application/json' },
    });
  }
}

async function staleWhileRevalidate(request) {
  const cache = await caches.open(DYNAMIC_CACHE);
  const cached = await cache.match(request);
  const networkFetch = fetch(request).then((response) => {
    if (response.ok) cache.put(request, response.clone());
    return response;
  }).catch(() => caches.match('/offline.html'));

  return cached ?? networkFetch;
}

The Offline Page

Every PWA should have a fallback /offline.html page to show when a navigation fails and there is no cached version. Keep it simple and informative:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>You are offline</title>
  <style>
    body {
      font-family: sans-serif;
      display: flex;
      flex-direction: column;
      align-items: center;
      justify-content: center;
      min-height: 100vh;
      margin: 0;
      background: #f9fafb;
      color: #111;
    }
    h1 { font-size: 1.5rem; }
    p  { color: #6b7280; }
  </style>
</head>
<body>
  <h1>You are offline</h1>
  <p>Check your internet connection and try again.</p>
  <button onclick="location.reload()">Try again</button>
</body>
</html>

PWA Installation: The "Add to Home Screen" Flow

When the browser determines your app meets the PWA installability criteria (HTTPS + service worker + valid manifest with icons), it may show an install prompt automatically. But you have more control than that.

The beforeinstallprompt Event

The browser fires beforeinstallprompt when it is ready to show the install prompt. You can capture it and show your own UI at a better moment:

// main.js

let deferredPrompt = null;
const installButton = document.querySelector('#install-btn');

window.addEventListener('beforeinstallprompt', (event) => {
  // Prevent the browser's default mini-infobar
  event.preventDefault();

  // Save the event for later use
  deferredPrompt = event;

  // Show your custom install button
  installButton.style.display = 'block';
});

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

  // Show the browser's install prompt
  deferredPrompt.prompt();

  // Wait for the user's choice
  const { outcome } = await deferredPrompt.userChoice;

  console.log(`User ${outcome === 'accepted' ? 'installed' : 'dismissed'} the app`);

  // The prompt can only be used once
  deferredPrompt = null;
  installButton.style.display = 'none';
});

// Track successful installations
window.addEventListener('appinstalled', () => {
  console.log('App installed to home screen');
  // Send an analytics event
});

This pattern lets you present the install offer at a moment of high engagement, like after a user completes a key action, rather than blasting a prompt the moment they land on the page.

Detecting if the App is Already Installed

You can detect whether the user is running your app in standalone mode (installed) vs. in the browser:

const isStandalone = window.matchMedia('(display-mode: standalone)').matches
  || window.navigator.standalone === true; // iOS Safari

if (isStandalone) {
  // Running as installed PWA
  // Hide the "install" UI, show "open in browser" if needed
}

iOS Safari Considerations

iOS Safari does not fire beforeinstallprompt. Installation is entirely manual ("Share > Add to Home Screen"). You need to show your own instruction UI to guide iOS users. Detect iOS and show a modal:

const isIos = /iphone|ipad|ipod/.test(navigator.userAgent.toLowerCase());
const isInStandaloneMode = window.navigator.standalone === true;

if (isIos && !isInStandaloneMode) {
  // Show "tap Share, then Add to Home Screen" instruction UI
  showIosInstallInstructions();
}

Push Notifications

Push notifications let your server send messages to users even when the browser is closed. They require two things: the Push API (for receiving messages) and the Notifications API (for displaying them).

The Permission Flow

You must ask the user for permission before sending notifications. Ask at a moment when the value is obvious, never on first page load:

// main.js

async function requestNotificationPermission() {
  if (!('Notification' in window)) {
    console.log('Notifications not supported');
    return;
  }

  if (Notification.permission === 'granted') return 'granted';
  if (Notification.permission === 'denied') return 'denied';

  const permission = await Notification.requestPermission();
  return permission;
}

// Only ask after a user action, like subscribing to a newsletter
document.querySelector('#subscribe-btn').addEventListener('click', async () => {
  const permission = await requestNotificationPermission();
  if (permission === 'granted') {
    await subscribeUserToPush();
  }
});

Subscribing to Push

The subscription process uses the Push API and requires a VAPID (Voluntary Application Server Identification) key pair. VAPID keys identify your server to the push service:

// main.js

async function subscribeUserToPush() {
  const registration = await navigator.serviceWorker.ready;

  const subscription = await registration.pushManager.subscribe({
    userVisibleOnly: true, // required to be true
    applicationServerKey: urlBase64ToUint8Array(YOUR_PUBLIC_VAPID_KEY),
  });

  // Send the subscription object to your backend
  await fetch('/api/push-subscriptions', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(subscription),
  });

  console.log('User subscribed to push notifications');
}

// Helper to convert VAPID key
function urlBase64ToUint8Array(base64String) {
  const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
  const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
  const rawData = atob(base64);
  return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0)));
}

Handling Push in the Service Worker

When your server sends a push message, the service worker receives it via the push event:

// sw.js

self.addEventListener('push', (event) => {
  if (!event.data) return;

  const data = event.data.json();

  const options = {
    body: data.body,
    icon: '/icons/icon-192x192.png',
    badge: '/icons/badge-72x72.png',
    image: data.image,
    data: { url: data.url },
    actions: [
      { action: 'open', title: 'Open' },
      { action: 'dismiss', title: 'Dismiss' },
    ],
    tag: data.tag,          // replaces existing notification with same tag
    renotify: true,         // vibrate even if same tag
    requireInteraction: false,
  };

  event.waitUntil(
    self.registration.showNotification(data.title, options)
  );
});

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

  if (event.action === 'dismiss') return;

  const url = event.notification.data.url ?? '/';
  event.waitUntil(
    clients.matchAll({ type: 'window' }).then((windowClients) => {
      // If a window is already open, focus it
      for (const client of windowClients) {
        if (client.url === url && 'focus' in client) {
          return client.focus();
        }
      }
      // Otherwise open a new window
      if (clients.openWindow) return clients.openWindow(url);
    })
  );
});

Sending Push from Your Server

On the server side, you use a library like web-push (Node.js) to send the push message:

// server.js (Node.js)
import webpush from 'web-push';

webpush.setVapidDetails(
  'mailto:hello@yourapp.com',
  process.env.VAPID_PUBLIC_KEY,
  process.env.VAPID_PRIVATE_KEY
);

async function sendPushNotification(subscription, payload) {
  try {
    await webpush.sendNotification(
      subscription,
      JSON.stringify({
        title: 'New message',
        body: 'You have a new message from Alice',
        url: '/messages/123',
        tag: 'message-123',
      })
    );
  } catch (error) {
    if (error.statusCode === 410) {
      // Subscription expired: remove from database
      await removeSubscription(subscription.endpoint);
    }
  }
}

The push message flows like this:

Push notification delivery flow diagram

Your server never communicates directly with the device. It sends to the push service, which queues and delivers the message. This is why push works even when the user is not actively browsing.


Background Sync

Background Sync solves a specific and frustrating problem: the user submits a form or sends a message, but their connection drops at that exact moment. Without background sync, the request fails silently and the user loses their work.

With background sync, the service worker queues the request and retries it automatically when connectivity is restored, even if the user has closed your app.

Registering a Sync

// main.js

async function sendMessageWithBackgroundSync(message) {
  // Save the message to IndexedDB first
  await saveToIndexedDB('outbox', message);

  // Register a sync event
  const registration = await navigator.serviceWorker.ready;
  await registration.sync.register('sync-outbox');

  console.log('Message queued for background sync');
}

Handling Sync in the Service Worker

// sw.js

self.addEventListener('sync', (event) => {
  if (event.tag === 'sync-outbox') {
    event.waitUntil(flushOutbox());
  }
});

async function flushOutbox() {
  const messages = await getAllFromIndexedDB('outbox');

  for (const message of messages) {
    try {
      const response = await fetch('/api/messages', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(message),
      });

      if (response.ok) {
        await removeFromIndexedDB('outbox', message.id);
      }
    } catch (error) {
      // Network still down: throw to signal the sync should retry
      throw error;
    }
  }
}

If flushOutbox() throws, the browser schedules another retry. The retry interval increases exponentially, and the browser will eventually give up after several hours. This is the right behavior: you don't want to retry a failed message forever.


Periodic Background Sync

A newer API, Periodic Background Sync, lets your service worker run on a schedule to refresh content, even while the app is closed. Think: a news app that pre-fetches articles every morning so they are ready when you open the app.

// main.js

async function registerPeriodicSync() {
  const registration = await navigator.serviceWorker.ready;

  const status = await navigator.permissions.query({
    name: 'periodic-background-sync',
  });

  if (status.state === 'granted') {
    await registration.periodicSync.register('refresh-content', {
      minInterval: 24 * 60 * 60 * 1000, // once per day at minimum
    });
  }
}
// sw.js

self.addEventListener('periodicsync', (event) => {
  if (event.tag === 'refresh-content') {
    event.waitUntil(refreshCachedContent());
  }
});

async function refreshCachedContent() {
  const cache = await caches.open('dynamic-v1');
  const urls = ['/api/posts', '/api/featured'];

  await Promise.all(
    urls.map(async (url) => {
      const response = await fetch(url);
      if (response.ok) cache.put(url, response);
    })
  );
}

The browser controls the actual schedule. It considers battery level, network conditions, and how often the user visits your app before deciding when to run. The minInterval is a hint, not a guarantee.


Workbox: The Production Tool

Writing service worker code by hand is educational but error-prone in production. Google's Workbox library provides battle-tested implementations of all the caching strategies, plus tooling to generate the app shell file list automatically.

Workbox with Vite

// vite.config.js
import { VitePWA } from 'vite-plugin-pwa';

export default {
  plugins: [
    VitePWA({
      registerType: 'autoUpdate',
      workbox: {
        // Pre-cache all build output
        globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],

        // Runtime caching rules
        runtimeCaching: [
          {
            // API calls: network first
            urlPattern: /^https:\/\/yourapp\.com\/api\//,
            handler: 'NetworkFirst',
            options: {
              cacheName: 'api-cache',
              expiration: { maxEntries: 100, maxAgeSeconds: 3600 },
              networkTimeoutSeconds: 5,
            },
          },
          {
            // Images: stale while revalidate
            urlPattern: /\.(?:png|jpg|jpeg|svg|gif|webp)$/,
            handler: 'StaleWhileRevalidate',
            options: {
              cacheName: 'image-cache',
              expiration: { maxEntries: 60, maxAgeSeconds: 30 * 24 * 3600 },
            },
          },
          {
            // Google Fonts: cache first (they use content hashes)
            urlPattern: /^https:\/\/fonts\.(googleapis|gstatic)\.com\//,
            handler: 'CacheFirst',
            options: {
              cacheName: 'font-cache',
              expiration: { maxAgeSeconds: 365 * 24 * 3600 },
            },
          },
        ],
      },
      manifest: {
        name: 'Clarity App',
        short_name: 'Clarity',
        theme_color: '#1e3a5f',
        icons: [
          { src: '/icons/icon-192x192.png', sizes: '192x192', type: 'image/png' },
          { src: '/icons/icon-512x512.png', sizes: '512x512', type: 'image/png' },
        ],
      },
    }),
  ],
};

Workbox also provides expiration settings to limit cache size and age: maxEntries caps how many responses are stored, and maxAgeSeconds evicts responses older than the threshold. Without these limits, your app's cache can grow unbounded on users' devices.


Auditing Your PWA with Lighthouse

Google's Lighthouse tool (built into Chrome DevTools) is the standard way to audit a PWA. It checks your manifest, service worker, HTTPS, and performance, giving you a score and specific action items.

Run it from Chrome DevTools > Lighthouse > Generate report.

A fully installable PWA passes these Lighthouse checks:

PWA Installability Checklist:
  [x] Served over HTTPS
  [x] Has a registered service worker
  [x] Has a web app manifest with:
      [x] name or short_name
      [x] icons (192px and 512px)
      [x] start_url
      [x] display: standalone, fullscreen, or minimal-ui
  [x] Service worker responds to fetch events
  [x] Manifest's start_url responds with 200 when offline

PWA Best Practices:
  [x] Has a <meta name="theme-color" /> tag
  [x] Has a <meta name="viewport" /> tag
  [x] Page loads on slow 3G in under 10 seconds
  [x] Each page has a unique URL

From the command line:

# Install Lighthouse CLI
npm install -g lighthouse

# Run a PWA audit
lighthouse https://yourapp.com --only-categories=pwa --output=html --output-path=./report.html

# Open the report
open report.html

Storage: Where PWAs Keep Data

Service workers have access to several storage APIs for persisting data on the device:

APIBest ForAccessible From
Cache Storage (caches.open())Network responses (HTML, CSS, JSON)SW + Page
IndexedDBStructured app data (user content, outbox queue)SW + Page
localStorageSimple key-value (small settings)Page only, NOT SW. Synchronous.
sessionStorageTab-scoped temporary statePage only, NOT SW

For service worker code, you have two choices: Cache Storage for network responses and IndexedDB for application data. localStorage is synchronous and is not available in service workers at all.

Checking Available Storage

The Storage Manager API tells you how much space your app is using and the browser's estimate of how much is available:

// main.js

async function checkStorageQuota() {
  if ('storage' in navigator && 'estimate' in navigator.storage) {
    const { usage, quota } = await navigator.storage.estimate();
    const usedMB = (usage / 1024 / 1024).toFixed(2);
    const quotaMB = (quota / 1024 / 1024).toFixed(2);
    console.log(`Using ${usedMB} MB of ${quotaMB} MB`);
  }
}

Browsers can evict cache storage under storage pressure. To opt your app's storage into "persistent" mode (not evicted without user approval):

async function requestPersistentStorage() {
  if (navigator.storage && navigator.storage.persist) {
    const granted = await navigator.storage.persist();
    console.log('Persistent storage granted:', granted);
  }
}

Common PWA Pitfalls

1. Caching API Responses That Contain User Data

Never cache responses that contain user-specific data (JWT tokens, personal data) in a shared cache. Use Cache-Control: private on the server for these responses, and check for it in your service worker:

// sw.js
function isCacheable(response) {
  const cacheControl = response.headers.get('Cache-Control') ?? '';
  return !cacheControl.includes('no-store') && !cacheControl.includes('private');
}

2. Not Versioning Your Cache

If you never change the cache name, users on old versions of your app will keep serving stale files forever. Always increment the cache version when you deploy:

// Wrong: cache never changes
const CACHE = 'my-cache';

// Right: version matches your deployment
const CACHE = 'app-shell-v14';

3. The Infinite Loop Trap

If your service worker fetches its own URL (e.g., a request to /sw.js goes through the fetch handler), you can create an infinite loop. Always skip service worker requests:

// sw.js
self.addEventListener('fetch', (event) => {
  if (event.request.url.includes('/sw.js')) return;
  // ... handle other requests
});

4. Serving Stale HTML That References Missing Assets

If you cache an old index.html that references /app.abc123.js, but the user's app shell cache only has /app.def456.js, the page will fail to load. The App Shell pattern solves this: always pre-cache HTML and its exact asset versions together under the same cache version name.

5. Not Handling the Update Flow

When you deploy a new version, users with the old service worker installed won't see updates until they close and reopen all tabs. Consider showing an "Update available" banner and prompting users to refresh:

// main.js

const registration = await navigator.serviceWorker.register('/sw.js');

registration.addEventListener('updatefound', () => {
  const newWorker = registration.installing;

  newWorker.addEventListener('statechange', () => {
    if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
      // A new version is waiting: show an update banner
      showUpdateBanner(() => {
        newWorker.postMessage({ type: 'SKIP_WAITING' });
        window.location.reload();
      });
    }
  });
});
// sw.js
self.addEventListener('message', (event) => {
  if (event.data?.type === 'SKIP_WAITING') {
    self.skipWaiting();
  }
});

PWA Capabilities by Platform

Browser and OS support for PWA APIs is not uniform. Here is the current state:

Feature                     Chrome  Firefox  Safari  Edge
                            (Android/Desktop)       (iOS 16.4+)
---------------------------------------------------------------
Service Workers             Yes     Yes      Yes     Yes
Cache API                   Yes     Yes      Yes     Yes
Web App Manifest            Yes     Yes      Yes     Yes
Install prompt              Yes     No       No      Yes
Push Notifications          Yes     Yes      Yes*    Yes
Background Sync             Yes     No       No      Yes
Periodic Background Sync    Yes     No       No      Yes
Badging API                 Yes     No       No      Yes
File System Access API      Yes     No       No      Yes
Web Share API               Yes     No       Yes     Yes

* iOS Push Notifications require iOS 16.4+ and the app must be installed

iOS Safari added web push support in iOS 16.4 (March 2023), but it only works for installed PWAs, not for pages open in the browser. The user must first "Add to Home Screen" before push notifications will arrive.

This is why the iOS install prompt UI (guiding users to tap "Share > Add to Home Screen") is important for any app that wants to send push notifications to iPhone users.


Summary: Building a PWA Checklist

Use this checklist when shipping a PWA to production:

FOUNDATION
  [ ] Site is served over HTTPS
  [ ] manifest.json is linked in every HTML page
  [ ] Service worker is registered
  [ ] An /offline.html fallback page exists

MANIFEST
  [ ] name and short_name set
  [ ] start_url includes analytics parameter (?source=pwa)
  [ ] display: "standalone"
  [ ] theme_color and background_color set
  [ ] 192x192 and 512x512 icons provided
  [ ] Maskable icon variants provided

SERVICE WORKER
  [ ] App shell pre-cached at install time
  [ ] Cache versioned and old caches cleaned up on activate
  [ ] Caching strategy chosen per resource type
  [ ] POST requests and cross-origin requests skipped
  [ ] Cache size limited with expiration rules
  [ ] Update flow handled (update banner + skipWaiting)

INSTALLATION
  [ ] beforeinstallprompt captured and deferred
  [ ] Install UI shown at appropriate moment
  [ ] iOS "Add to Home Screen" instructions shown for Safari users
  [ ] appinstalled event tracked in analytics

PUSH NOTIFICATIONS (if using)
  [ ] VAPID keys generated
  [ ] Permission requested after user action
  [ ] Subscription sent to server
  [ ] Notification click opens the right URL
  [ ] Subscription expiry (410) handled on server

AUDITING
  [ ] Lighthouse PWA audit passes
  [ ] Tested offline in DevTools Network panel
  [ ] Tested install flow on Android Chrome
  [ ] Tested on iOS Safari (16.4+)

What to Explore Next

PWAs open the door to a broader set of browser capabilities that are evolving rapidly:

  • Web Share API: let users share content to other apps natively
  • Web Bluetooth and Web USB: connect to hardware devices from the browser
  • File System Access API: read and write files on the user's local filesystem
  • Screen Wake Lock API: prevent the screen from sleeping during active use (maps, recipes, workouts)
  • Badging API: show a notification count on the app icon in the taskbar
  • Project Fugu: Google's initiative to close the remaining gap between web and native capabilities

The web platform is closing the gap with native faster than ever. PWAs are no longer a compromise, they are a genuinely competitive choice.

Thanks for reading!

Clarity Technologies

We strive for nothing less than the Perfection & Quality.

Understands your business and delivering Future.

© 2026 Clarity, Technologies. All rights reserved.