Skip to content
Published on

PWA Complete Guide 2025: Native-Quality Web Experiences

Authors

Introduction

In 2025, PWA (Progressive Web App) is no longer an experimental technology. Major services like Twitter (X), Starbucks, Pinterest, and Spotify have adopted PWAs to deliver user experiences rivaling native apps. Google Play Store and Microsoft Store allow direct PWA listings, and iOS is progressively expanding PWA support.

This guide systematically covers Service Worker lifecycle, 5 caching strategies, Workbox usage, push notifications, offline-first architecture, Next.js PWA integration, and app store distribution.

Key Expressions

ExpressionMeaning
Service WorkerJavaScript proxy running in browser background. Intercepts network requests, handles caching and push notifications
Cache-First StrategyResponds from cache first, falls back to network. Core of offline support
Web App ManifestJSON file defining PWA metadata. App name, icons, start URL, display mode, etc.
WorkboxGoogle's Service Worker library. Simplifies caching strategies, precaching, and routing

1. What Is a PWA?

1.1 Core Characteristics of PWA

A PWA is an application built with web technologies that delivers native app-like experiences.

Three Core Pillars of PWA:

┌─────────────────────────────────────────┐
Progressive Web App├─────────────┬─────────────┬─────────────┤
ReliableFastEngaging│             │             │             │
- Offline- Instant- Home- Stable    │   load      │   screen    │
│   network   │ - Smooth- Fullscreen│
│             │   scroll    │ - Push│             │ - 60fps     │ - Installable│
└─────────────┴─────────────┴─────────────┘

1.2 PWA vs Native App vs Regular Web

FeatureRegular WebPWANative App
InstallationNoneOptionalRequired
Offline SupportNoneYesYes
Push NotificationsNoneYesYes
App StoreNoYes (TWA/PWABuilder)Default
UpdatesInstantInstant/BackgroundStore review
File Size0 (URL access)KB to MBTens to hundreds of MB
Hardware AccessLimitedExpandingFull
Development CostLowLowHigh (per platform)
SEOExcellentExcellentNone
Cross PlatformBrowserBrowserPlatform-specific

1.3 PWA Success Stories

Major Company PWA Results:

Twitter Lite:
- 70% reduction in data consumption per page
- 75% increase in tweets sent
- 20% reduction in bounce rate

Pinterest:
- 44% increase in ad revenue
- 60% increase in core engagement
- 40% increase in logged-in users

Starbucks:
- 99.84% smaller than native app
- 2x increase in daily active users

Uber:
- Loads in 3 seconds on 3G
- Core app size just 50KB

2. Service Worker Lifecycle

2.1 What Is a Service Worker?

A Service Worker is a proxy script that operates between the browser and the network. It runs on a separate thread from the main thread and cannot directly access the DOM.

┌─────────────┐     ┌──────────────┐     ┌─────────────┐
Web App   │────▶│Service Worker│────▶│   Network  (Main  (Proxy)  (Server)Thread)   │◀────│              │◀────│             │
└─────────────┘     │   ┌──────┐   │     └─────────────┘
                    │   │Cache │   │
                    │   │ API  │   │
                    │   └──────┘   │
                    └──────────────┘

2.2 Lifecycle Stages

Service Worker Lifecycle:

1. Registration
   └─▶ navigator.serviceWorker.register('/sw.js')

2. Installation
   └─▶ 'install' event fires
   └─▶ Precache static assets

3. Waiting
   └─▶ Waits if previous SW is active

4. Activation
   └─▶ 'activate' event fires
   └─▶ Clean up old caches

5. Fetch
   └─▶ Control network requests via 'fetch' event

6. Idle / Terminated
   └─▶ Browser can terminate when no events

2.3 Basic Service Worker Implementation

// sw.js - Service Worker file
const CACHE_NAME = 'my-pwa-v1';
const STATIC_ASSETS = [
  '/',
  '/index.html',
  '/styles/main.css',
  '/scripts/app.js',
  '/images/logo.png',
  '/offline.html',
];

// Install event: precache static assets
self.addEventListener('install', (event) => {
  console.log('[SW] Installing...');
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then((cache) => {
        console.log('[SW] Pre-caching static assets');
        return cache.addAll(STATIC_ASSETS);
      })
      .then(() => self.skipWaiting())
  );
});

// Activate event: clean old caches
self.addEventListener('activate', (event) => {
  console.log('[SW] Activating...');
  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames
          .filter((name) => name !== CACHE_NAME)
          .map((name) => {
            console.log('[SW] Deleting old cache:', name);
            return caches.delete(name);
          })
      );
    }).then(() => self.clients.claim())
  );
});

// Fetch event: intercept requests
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request)
      .then((cachedResponse) => {
        if (cachedResponse) {
          return cachedResponse;
        }
        return fetch(event.request);
      })
      .catch(() => {
        if (event.request.mode === 'navigate') {
          return caches.match('/offline.html');
        }
      })
  );
});

2.4 Registration Code

// main.js - App entry point
if ('serviceWorker' in navigator) {
  window.addEventListener('load', async () => {
    try {
      const registration = await navigator.serviceWorker.register('/sw.js', {
        scope: '/',
      });

      console.log('SW registered:', registration.scope);

      registration.addEventListener('updatefound', () => {
        const newWorker = registration.installing;
        newWorker.addEventListener('statechange', () => {
          if (newWorker.state === 'installed') {
            if (navigator.serviceWorker.controller) {
              showUpdateNotification();
            }
          }
        });
      });
    } catch (error) {
      console.error('SW registration failed:', error);
    }
  });
}

function showUpdateNotification() {
  const banner = document.createElement('div');
  banner.innerHTML = 'New version available. <button id="reload">Update</button>';
  banner.className = 'update-banner';
  document.body.appendChild(banner);

  document.getElementById('reload').addEventListener('click', () => {
    window.location.reload();
  });
}

3. Five Caching Strategies

3.1 Cache First

Serve from cache if available, otherwise fetch from network. Optimal for static assets.

// Cache First strategy
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((cached) => {
      return cached || fetch(event.request).then((response) => {
        const responseClone = response.clone();
        caches.open(CACHE_NAME).then((cache) => {
          cache.put(event.request, responseClone);
        });
        return response;
      });
    })
  );
});
Request flow:
Client ──▶ Cache ──(hit)──▶ Return response
              
           (miss)
           Network ──▶ Return response + Store in cache

3.2 Network First

Try network first, fall back to cache on failure. Ideal for API data.

// Network First strategy
self.addEventListener('fetch', (event) => {
  event.respondWith(
    fetch(event.request)
      .then((response) => {
        const responseClone = response.clone();
        caches.open(CACHE_NAME).then((cache) => {
          cache.put(event.request, responseClone);
        });
        return response;
      })
      .catch(() => caches.match(event.request))
  );
});

3.3 Stale While Revalidate

Respond immediately from cache while updating the cache from network in background.

// Stale While Revalidate strategy
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.open(CACHE_NAME).then((cache) => {
      return cache.match(event.request).then((cached) => {
        const fetchPromise = fetch(event.request).then((networkResponse) => {
          cache.put(event.request, networkResponse.clone());
          return networkResponse;
        });
        return cached || fetchPromise;
      });
    })
  );
});

3.4 Cache Only / Network Only

// Cache Only - only for precached resources
self.addEventListener('fetch', (event) => {
  event.respondWith(caches.match(event.request));
});

// Network Only - no caching needed (e.g., analytics)
self.addEventListener('fetch', (event) => {
  event.respondWith(fetch(event.request));
});

3.5 Strategy Selection Guide

StrategyUse CaseProsCons
Cache FirstStatic assets (CSS, JS, images)Fast response, offlineDelayed updates
Network FirstAPI data, news feedsAlways freshSlow on poor network
Stale While RevalidateFrequently changing static assetsFast and freshFirst request may be stale
Cache OnlyVersioned resourcesFull offlineCannot update
Network OnlyPayments, authAlways currentNo offline

4. Web App Manifest

4.1 Writing the Manifest

{
  "name": "My Progressive Web App",
  "short_name": "MyPWA",
  "description": "An awesome PWA that works offline",
  "start_url": "/?source=pwa",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#2196F3",
  "orientation": "portrait-primary",
  "scope": "/",
  "lang": "en",
  "dir": "ltr",
  "categories": ["productivity", "utilities"],
  "icons": [
    {
      "src": "/icons/icon-72x72.png",
      "sizes": "72x72",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-96x96.png",
      "sizes": "96x96",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-128x128.png",
      "sizes": "128x128",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "any maskable"
    },
    {
      "src": "/icons/icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "any maskable"
    }
  ],
  "screenshots": [
    {
      "src": "/screenshots/desktop.png",
      "sizes": "1280x720",
      "type": "image/png",
      "form_factor": "wide",
      "label": "Desktop view"
    },
    {
      "src": "/screenshots/mobile.png",
      "sizes": "750x1334",
      "type": "image/png",
      "form_factor": "narrow",
      "label": "Mobile view"
    }
  ],
  "shortcuts": [
    {
      "name": "New Task",
      "short_name": "Task",
      "url": "/tasks/new",
      "icons": [{ "src": "/icons/task.png", "sizes": "96x96" }]
    }
  ]
}

4.2 Linking Manifest in HTML

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">

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

  <!-- iOS support -->
  <meta name="apple-mobile-web-app-capable" content="yes">
  <meta name="apple-mobile-web-app-status-bar-style" content="default">
  <meta name="apple-mobile-web-app-title" content="MyPWA">
  <link rel="apple-touch-icon" href="/icons/icon-152x152.png">

  <!-- Theme color -->
  <meta name="theme-color" content="#2196F3">

  <title>My PWA</title>
</head>
<body>
  <!-- App content -->
</body>
</html>

4.3 Display Modes Comparison

ModeDescriptionBrowser UI
fullscreenFull screen, status bar hiddenNone
standaloneDisplayed as standalone appStatus bar only
minimal-uiMinimal browser UIBack/Refresh buttons
browserRegular browser tabFull browser UI

5. Service Worker with Workbox

5.1 Workbox Introduction

Workbox is a Service Worker library by Google that lets you implement caching strategies declaratively.

# Install Workbox CLI
npm install workbox-cli --save-dev

# Or Webpack plugin
npm install workbox-webpack-plugin --save-dev

5.2 Implementing Caching Strategies with Workbox

// sw.js with Workbox
import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import {
  CacheFirst,
  NetworkFirst,
  StaleWhileRevalidate,
} from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
import { CacheableResponsePlugin } from 'workbox-cacheable-response';

// Precaching (injected at build time)
precacheAndRoute(self.__WB_MANIFEST);

// Images: Cache First + expiration
registerRoute(
  ({ request }) => request.destination === 'image',
  new CacheFirst({
    cacheName: 'images-cache',
    plugins: [
      new CacheableResponsePlugin({ statuses: [0, 200] }),
      new ExpirationPlugin({
        maxEntries: 100,
        maxAgeSeconds: 30 * 24 * 60 * 60, // 30 days
      }),
    ],
  })
);

// CSS/JS: Stale While Revalidate
registerRoute(
  ({ request }) =>
    request.destination === 'style' ||
    request.destination === 'script',
  new StaleWhileRevalidate({
    cacheName: 'static-resources',
    plugins: [
      new CacheableResponsePlugin({ statuses: [0, 200] }),
    ],
  })
);

// API calls: Network First
registerRoute(
  ({ url }) => url.pathname.startsWith('/api/'),
  new NetworkFirst({
    cacheName: 'api-cache',
    networkTimeoutSeconds: 5,
    plugins: [
      new CacheableResponsePlugin({ statuses: [0, 200] }),
      new ExpirationPlugin({
        maxEntries: 50,
        maxAgeSeconds: 5 * 60, // 5 minutes
      }),
    ],
  })
);

// Google Fonts: Cache First
registerRoute(
  ({ url }) => url.origin === 'https://fonts.googleapis.com' ||
               url.origin === 'https://fonts.gstatic.com',
  new CacheFirst({
    cacheName: 'google-fonts',
    plugins: [
      new CacheableResponsePlugin({ statuses: [0, 200] }),
      new ExpirationPlugin({
        maxEntries: 30,
        maxAgeSeconds: 365 * 24 * 60 * 60, // 1 year
      }),
    ],
  })
);

5.3 Background Sync

// Background Sync for offline form submission
import { BackgroundSyncPlugin } from 'workbox-background-sync';
import { registerRoute } from 'workbox-routing';
import { NetworkOnly } from 'workbox-strategies';

const bgSyncPlugin = new BackgroundSyncPlugin('formQueue', {
  maxRetentionTime: 24 * 60, // 24 hours (in minutes)
  onSync: async ({ queue }) => {
    let entry;
    while ((entry = await queue.shiftRequest())) {
      try {
        await fetch(entry.request);
        console.log('[BG Sync] Replayed:', entry.request.url);
      } catch (error) {
        console.error('[BG Sync] Failed:', error);
        await queue.unshiftRequest(entry);
        throw error;
      }
    }
  },
});

// Apply Background Sync to POST requests
registerRoute(
  ({ url, request }) =>
    url.pathname === '/api/submit' && request.method === 'POST',
  new NetworkOnly({
    plugins: [bgSyncPlugin],
  }),
  'POST'
);

6. Push Notifications

6.1 Push Notification Architecture

Push Notification Flow:

┌──────────┐  Subscribe ┌──────────────┐
Client  │──────────▶│  Push Server (Browser) (FCM/APNS)└────┬─────┘           └──────┬───────┘
Send subscription       │
     │ info                    │
     ▼                        │
┌──────────┐  Send push  ◀───┘
AppServer└──────────┘

6.2 Subscription Implementation

// Client: Subscribe to push notifications
async function subscribePush() {
  const permission = await Notification.requestPermission();
  if (permission !== 'granted') {
    console.log('Notification permission denied');
    return;
  }

  const registration = await navigator.serviceWorker.ready;

  const vapidPublicKey = 'YOUR_VAPID_PUBLIC_KEY';
  const convertedKey = urlBase64ToUint8Array(vapidPublicKey);

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

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

  console.log('Push subscription successful');
}

function urlBase64ToUint8Array(base64String) {
  const padding = '='.repeat((4 - base64String.length % 4) % 4);
  const base64 = (base64String + padding)
    .replace(/-/g, '+')
    .replace(/_/g, '/');
  const rawData = window.atob(base64);
  const outputArray = new Uint8Array(rawData.length);
  for (let i = 0; i < rawData.length; ++i) {
    outputArray[i] = rawData.charCodeAt(i);
  }
  return outputArray;
}

6.3 Server-Side Push Sending

// Node.js server: web-push library
const webpush = require('web-push');

webpush.setVapidDetails(
  'mailto:admin@example.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: 'John sent you a message.',
        icon: '/icons/icon-192x192.png',
        badge: '/icons/badge-72x72.png',
        data: {
          url: '/messages/123',
          timestamp: Date.now(),
        },
        actions: [
          { action: 'reply', title: 'Reply', icon: '/icons/reply.png' },
          { action: 'dismiss', title: 'Dismiss', icon: '/icons/close.png' },
        ],
      })
    );
    console.log('Push sent successfully');
  } catch (error) {
    if (error.statusCode === 410) {
      await removeSubscription(subscription.endpoint);
    }
    console.error('Push failed:', error);
  }
}

6.4 Receiving Push in Service Worker

// sw.js: Handle push events
self.addEventListener('push', (event) => {
  const data = event.data ? event.data.json() : {};

  const options = {
    body: data.body || 'New notification',
    icon: data.icon || '/icons/icon-192x192.png',
    badge: data.badge || '/icons/badge-72x72.png',
    vibrate: [200, 100, 200],
    data: data.data || {},
    actions: data.actions || [],
    tag: data.tag || 'default',
    renotify: true,
  };

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

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

  const urlToOpen = event.notification.data.url || '/';

  if (event.action === 'reply') {
    event.waitUntil(clients.openWindow(urlToOpen + '?action=reply'));
  } else {
    event.waitUntil(
      clients.matchAll({ type: 'window' }).then((clientList) => {
        for (const client of clientList) {
          if (client.url === urlToOpen && 'focus' in client) {
            return client.focus();
          }
        }
        return clients.openWindow(urlToOpen);
      })
    );
  }
});

7. Offline-First Architecture

7.1 Local Data Storage with IndexedDB

// IndexedDB wrapper (using idb library)
import { openDB } from 'idb';

class LocalStore {
  constructor(dbName, storeName) {
    this.dbName = dbName;
    this.storeName = storeName;
  }

  async getDB() {
    return openDB(this.dbName, 1, {
      upgrade(db) {
        if (!db.objectStoreNames.contains('tasks')) {
          const store = db.createObjectStore('tasks', {
            keyPath: 'id',
            autoIncrement: true,
          });
          store.createIndex('status', 'status');
          store.createIndex('syncStatus', 'syncStatus');
          store.createIndex('updatedAt', 'updatedAt');
        }
      },
    });
  }

  async getAll() {
    const db = await this.getDB();
    return db.getAll(this.storeName);
  }

  async add(item) {
    const db = await this.getDB();
    item.syncStatus = 'pending';
    item.updatedAt = Date.now();
    return db.add(this.storeName, item);
  }

  async update(item) {
    const db = await this.getDB();
    item.syncStatus = 'pending';
    item.updatedAt = Date.now();
    return db.put(this.storeName, item);
  }

  async getPendingSync() {
    const db = await this.getDB();
    const tx = db.transaction(this.storeName, 'readonly');
    const index = tx.store.index('syncStatus');
    return index.getAll('pending');
  }
}

const taskStore = new LocalStore('myApp', 'tasks');
await taskStore.add({
  title: 'New task',
  status: 'active',
  createdAt: Date.now(),
});

7.2 Sync Strategy

// Sync when coming back online
class SyncManager {
  constructor(localStore, apiUrl) {
    this.localStore = localStore;
    this.apiUrl = apiUrl;
    this.setupListeners();
  }

  setupListeners() {
    window.addEventListener('online', () => this.sync());
  }

  async sync() {
    const pendingItems = await this.localStore.getPendingSync();

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

        if (response.ok) {
          item.syncStatus = 'synced';
          await this.localStore.update(item);
        }
      } catch (error) {
        console.error('Sync failed for item:', item.id, error);
      }
    }
  }
}

8. Next.js PWA Integration

8.1 next-pwa Setup

npm install @ducanh2912/next-pwa
// next.config.js
const withPWA = require('@ducanh2912/next-pwa').default({
  dest: 'public',
  disable: process.env.NODE_ENV === 'development',
  register: true,
  skipWaiting: true,
  cacheOnFrontEndNav: true,
  workboxOptions: {
    runtimeCaching: [
      {
        urlPattern: /^https:\/\/fonts\.(?:googleapis|gstatic)\.com\/.*/i,
        handler: 'CacheFirst',
        options: {
          cacheName: 'google-fonts',
          expiration: {
            maxEntries: 4,
            maxAgeSeconds: 365 * 24 * 60 * 60,
          },
        },
      },
      {
        urlPattern: /\.(?:jpg|jpeg|gif|png|svg|ico|webp)$/i,
        handler: 'StaleWhileRevalidate',
        options: {
          cacheName: 'static-image-assets',
          expiration: {
            maxEntries: 64,
            maxAgeSeconds: 24 * 60 * 60,
          },
        },
      },
      {
        urlPattern: /\/api\/.*$/i,
        handler: 'NetworkFirst',
        options: {
          cacheName: 'apis',
          networkTimeoutSeconds: 10,
          expiration: {
            maxEntries: 16,
            maxAgeSeconds: 24 * 60 * 60,
          },
        },
      },
    ],
  },
});

module.exports = withPWA({
  reactStrictMode: true,
});

8.2 App Router Manifest

// app/manifest.ts (Next.js App Router)
import type { MetadataRoute } from 'next';

export default function manifest(): MetadataRoute.Manifest {
  return {
    name: 'My Next.js PWA',
    short_name: 'NextPWA',
    description: 'A Progressive Web App built with Next.js',
    start_url: '/',
    display: 'standalone',
    background_color: '#ffffff',
    theme_color: '#000000',
    icons: [
      {
        src: '/icons/icon-192x192.png',
        sizes: '192x192',
        type: 'image/png',
      },
      {
        src: '/icons/icon-512x512.png',
        sizes: '512x512',
        type: 'image/png',
      },
    ],
  };
}

8.3 Offline Page Handling

// app/offline/page.tsx
export default function OfflinePage() {
  return (
    <div className="flex flex-col items-center justify-center min-h-screen">
      <h1 className="text-3xl font-bold mb-4">You are offline</h1>
      <p className="text-gray-600 mb-8">
        Please check your internet connection.
      </p>
      <button
        onClick={() => window.location.reload()}
        className="px-6 py-3 bg-blue-500 text-white rounded-lg"
      >
        Try Again
      </button>
    </div>
  );
}

9. App Store Distribution

9.1 Google Play Store (TWA)

You can publish PWAs to the Google Play Store using Trusted Web Activity (TWA).

# Create TWA with Bubblewrap
npx @nicolo-ribaudo/bubblewrap init --manifest https://myapp.com/manifest.json
npx @nicolo-ribaudo/bubblewrap build
Google Play Deployment Steps:

1. Set up Digital Asset Links
   - Create /.well-known/assetlinks.json file
   - Include SHA-256 fingerprint of app signing certificate

2. Create TWA project
   - Use Bubblewrap or PWABuilder
   - Auto-generates Android project

3. Sign and build
   - Generate Android keystore
   - Build APK or AAB

4. Upload to Play Console
   - Select internal/closed/production track
   - Complete store listing

9.2 Microsoft Store (PWABuilder)

Microsoft Store Deployment Steps:

1. Visit PWABuilder (https://www.pwabuilder.com/)
2. Enter PWA URL
3. Review score and download package
4. Generate MSIX package
5. Register app in Partner Center
6. Upload MSIX package

9.3 iOS (Safari Support)

iOS PWA Limitations (2025):

Supported:
- Add to Home Screen
- Offline caching
- Web App Manifest (partial)
- Basic push notifications (iOS 16.4+)

Limited:
- No Background Sync
- 50MB storage limit
- Cannot install PWA from third-party browsers
- Some Web APIs not supported

10. PWA Performance Optimization

10.1 Lighthouse PWA Checklist

Lighthouse PWA Checklist:

[Required]
- Uses HTTPS
- Service Worker registered
- Responds with 200 when offline
- Web App Manifest present
- start_url set
- Icons (192px, 512px)
- viewport meta tag
- theme-color meta tag

[Recommended]
- Redirect HTTP to HTTPS
- Fast load on 3G (under 10s)
- Custom splash screen
- Address bar theme color
- Content sized to viewport
- apple-touch-icon set

10.2 Cache Size Management

// Monitor cache size
async function getCacheSize() {
  if ('storage' in navigator && 'estimate' in navigator.storage) {
    const estimate = await navigator.storage.estimate();
    const usedMB = (estimate.usage / (1024 * 1024)).toFixed(2);
    const quotaMB = (estimate.quota / (1024 * 1024)).toFixed(2);
    console.log('Storage used:', usedMB, 'MB of', quotaMB, 'MB');
    return estimate;
  }
}

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

10.3 Custom Install Prompt

// Control install prompt
let deferredPrompt;

window.addEventListener('beforeinstallprompt', (e) => {
  e.preventDefault();
  deferredPrompt = e;
  showInstallButton();
});

function showInstallButton() {
  const installBtn = document.getElementById('install-btn');
  installBtn.style.display = 'block';

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

    deferredPrompt.prompt();
    const result = await deferredPrompt.userChoice;

    console.log('Install prompt result:', result.outcome);
    deferredPrompt = null;
    installBtn.style.display = 'none';
  });
}

// Detect installation
window.addEventListener('appinstalled', () => {
  console.log('PWA was installed');
  deferredPrompt = null;
  analytics.track('pwa_installed');
});

11. Practice Quiz

Work through each problem and check your answers.

Q1. List the Service Worker lifecycle stages in order.

Registration - Installation - Waiting - Activation - Fetch (Idle/Terminated)

After Registration, the Installation stage precaches static assets. If a previous SW exists, it enters the Waiting state. Activation cleans up old caches, and then Fetch events control network requests.

Q2. What resource type is Cache-First strategy best suited for?

Static assets (CSS, JS bundles, images, fonts)

Best for resources with low change frequency and version management. Responds instantly from cache and works offline. For frequently changing data like API responses, use Network First or Stale While Revalidate instead.

Q3. What does display: standalone mean in Web App Manifest?

Displayed as a standalone app without the browser address bar

In standalone mode, only the status bar is shown, making it look like a native app. fullscreen hides even the status bar, minimal-ui shows minimal browser UI (back button, etc.), and browser opens in a regular browser tab.

Q4. What is the purpose of Workbox's BackgroundSyncPlugin?

Stores failed network requests in a queue while offline and automatically retries them when connectivity is restored

For example, if a form submission occurs offline, the request is saved to IndexedDB and automatically sent to the server when the network recovers. This enables data-loss-free operation even while offline.

Q5. What technology is used to publish a PWA on the Google Play Store?

TWA (Trusted Web Activity)

TWA wraps a PWA as a native Android app based on Chrome Custom Tabs. A Digital Asset Links file (assetlinks.json) proves ownership between the app and website. Tools like Bubblewrap or PWABuilder make it easy to create TWA projects.


12. References

  1. web.dev PWA Guide - Google's official PWA guide
  2. MDN Service Worker API - Service Worker API documentation
  3. Workbox Documentation - Workbox library docs
  4. PWABuilder - PWA build and deployment tool
  5. Web Push Protocol - Web push notification overview
  6. Lighthouse - PWA audit tool
  7. next-pwa - Next.js PWA plugin
  8. idb - IndexedDB Promise wrapper
  9. Project Fugu - Web platform API tracker
  10. Bubblewrap - TWA build tool
  11. What PWA Can Do Today - PWA capability demos
  12. Microsoft PWA Docs - Microsoft Edge PWA guide
  13. Apple Developer - Web Apps - iOS PWA documentation