Progressive Web App Implementation Stack

1. Service Worker Workbox Caching Strategies

Strategy Description & Use Cases Implementation Best Practices
Cache First (CacheFirst) Check cache first, network fallback. Best for: static assets, fonts, images, CSS, JS bundles Workbox: new CacheFirst({ cacheName, plugins }). Max age 1 year. Cache on install Use for immutable assets. Set max-age 1yr. Cache bust with hashes. Limit cache size 50MB
Network First (NetworkFirst) Try network, cache fallback. Best for: API calls, dynamic content, news feeds, user data new NetworkFirst({ cacheName, networkTimeoutSeconds: 3 }). Timeout to cache Use for fresh data priority. 3s timeout. Cache as backup. Good for offline experience
Stale While Revalidate Return cache, update background. Best for: avatars, non-critical content, analytics new StaleWhileRevalidate({ cacheName }). Instant response + background update Best UX for semi-fresh data. Fast response. Update silently. Use for profile pics, icons
Network Only Always network, never cache. Best for: POST/PUT/DELETE, payments, auth, real-time data new NetworkOnly(). No caching. Fail if offline. Critical data only Use for mutations. Payment APIs. Auth endpoints. Never cache sensitive data
Cache Only Only from cache, no network. Best for: offline-first, precached shells, fallback pages new CacheOnly({ cacheName }). Must precache. Offline page. App shell Precache on install. Offline fallback page. App shell. Never stale content
Workbox Strategies Plugin system, expiration, cache size, broadcast updates, background sync ExpirationPlugin (maxAge, maxEntries). CacheableResponsePlugin. BroadcastUpdatePlugin Limit cache 50MB. Expire after 30d. Cache 200 responses only. Broadcast cache updates

Example: Workbox service worker with caching strategies

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

// Precache app shell and static assets
precacheAndRoute(self.__WB_MANIFEST);

// Cache static assets (images, fonts, CSS, JS)
registerRoute(
  ({ request }) => ['image', 'font', 'style', 'script'].includes(request.destination),
  new CacheFirst({
    cacheName: 'static-assets-v1',
    plugins: [
      new CacheableResponsePlugin({
        statuses: [0, 200],
      }),
      new ExpirationPlugin({
        maxEntries: 60,
        maxAgeSeconds: 30 * 24 * 60 * 60, // 30 days
        purgeOnQuotaError: true,
      }),
    ],
  })
);

// Cache API responses with network first
registerRoute(
  ({ url }) => url.pathname.startsWith('/api/'),
  new NetworkFirst({
    cacheName: 'api-cache-v1',
    networkTimeoutSeconds: 3,
    plugins: [
      new CacheableResponsePlugin({
        statuses: [200],
      }),
      new ExpirationPlugin({
        maxEntries: 50,
        maxAgeSeconds: 5 * 60, // 5 minutes
      }),
    ],
  })
);

// Stale-while-revalidate for avatars and images
registerRoute(
  ({ url }) => url.pathname.match(/\.(jpg|jpeg|png|gif|webp)$/),
  new StaleWhileRevalidate({
    cacheName: 'images-v1',
    plugins: [
      new ExpirationPlugin({
        maxEntries: 100,
        maxAgeSeconds: 7 * 24 * 60 * 60, // 7 days
      }),
    ],
  })
);

// Network only for authentication and mutations
registerRoute(
  ({ url, request }) => 
    url.pathname.startsWith('/api/auth') || 
    ['POST', 'PUT', 'DELETE'].includes(request.method),
  new NetworkOnly()
);

// Offline fallback page
const OFFLINE_URL = '/offline.html';

self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open('offline-v1').then((cache) => cache.add(OFFLINE_URL))
  );
});

self.addEventListener('fetch', (event) => {
  if (event.request.mode === 'navigate') {
    event.respondWith(
      fetch(event.request).catch(() => 
        caches.match(OFFLINE_URL)
      )
    );
  }
});

Example: Register service worker in React

// src/serviceWorkerRegistration.ts
export function register() {
  if ('serviceWorker' in navigator) {
    window.addEventListener('load', () => {
      navigator.serviceWorker
        .register('/service-worker.js')
        .then((registration) => {
          console.log('SW registered:', registration);
          
          // Check for updates every hour
          setInterval(() => {
            registration.update();
          }, 60 * 60 * 1000);
          
          // Listen for updates
          registration.addEventListener('updatefound', () => {
            const newWorker = registration.installing;
            newWorker?.addEventListener('statechange', () => {
              if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
                // New content available, show update prompt
                if (confirm('New version available! Reload to update?')) {
                  newWorker.postMessage({ type: 'SKIP_WAITING' });
                  window.location.reload();
                }
              }
            });
          });
        })
        .catch((error) => {
          console.error('SW registration failed:', error);
        });
    });
  }
}

// src/main.tsx
import { register } from './serviceWorkerRegistration';

register();

2. Web App Manifest PWA Installation

Property Description Example Values Best Practices
name Full app name shown on splash screen and app list. 45 characters max "My Awesome PWA Application" Descriptive, brandable. Same as page title. Avoid generic names
short_name Short name for home screen. 12 characters max fits all devices "MyPWA" Keep under 12 chars. Still recognizable. Used as icon label. Test on devices
icons App icons for home screen, splash screen. Multiple sizes required 192x192, 512x512 (maskable), 180x180 (iOS) Provide 192x192 (Android), 512x512 (splash), maskable icons. PNG format. Transparent
start_url URL loaded when app launches. Track PWA installs with query param "/?source=pwa" Add UTM params for analytics. Must be in scope. Relative path preferred
display Display mode: standalone, fullscreen, minimal-ui, browser "standalone" Use standalone for app-like. fullscreen for games. minimal-ui for tools. Browser fallback
theme_color & background_color Theme: status bar color. Background: splash screen while loading "#1976d2", "#ffffff" Match brand colors. Theme for browser UI. Background for splash. Update meta tags

Example: Complete manifest.json

// public/manifest.json
{
  "name": "My Progressive Web App",
  "short_name": "MyPWA",
  "description": "A modern progressive web application with offline support",
  "start_url": "/?source=pwa",
  "scope": "/",
  "display": "standalone",
  "orientation": "portrait-primary",
  "theme_color": "#1976d2",
  "background_color": "#ffffff",
  "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-144x144.png",
      "sizes": "144x144",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "any"
    },
    {
      "src": "/icons/icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "any"
    },
    {
      "src": "/icons/icon-maskable-192x192.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "maskable"
    },
    {
      "src": "/icons/icon-maskable-512x512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "maskable"
    }
  ],
  "screenshots": [
    {
      "src": "/screenshots/desktop-1.png",
      "sizes": "1280x720",
      "type": "image/png",
      "form_factor": "wide"
    },
    {
      "src": "/screenshots/mobile-1.png",
      "sizes": "750x1334",
      "type": "image/png",
      "form_factor": "narrow"
    }
  ],
  "categories": ["productivity", "utilities"],
  "lang": "en-US",
  "dir": "ltr",
  "prefer_related_applications": false
}

// index.html - Link manifest and meta tags
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  
  <!-- PWA Manifest -->
  <link rel="manifest" href="/manifest.json">
  
  <!-- Theme colors -->
  <meta name="theme-color" content="#1976d2">
  <meta name="theme-color" media="(prefers-color-scheme: dark)" content="#121212">
  
  <!-- iOS specific -->
  <meta name="apple-mobile-web-app-capable" content="yes">
  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
  <meta name="apple-mobile-web-app-title" content="MyPWA">
  <link rel="apple-touch-icon" href="/icons/icon-180x180.png">
  
  <!-- Windows specific -->
  <meta name="msapplication-TileColor" content="#1976d2">
  <meta name="msapplication-TileImage" content="/icons/icon-144x144.png">
</head>
</html>

Example: Install prompt handler

// React component for install prompt
import { useState, useEffect } from 'react';

export function InstallPWA() {
  const [deferredPrompt, setDeferredPrompt] = useState<any>(null);
  const [showInstall, setShowInstall] = useState(false);

  useEffect(() => {
    const handler = (e: Event) => {
      e.preventDefault();
      setDeferredPrompt(e);
      setShowInstall(true);
    };

    window.addEventListener('beforeinstallprompt', handler);
    
    // Check if already installed
    if (window.matchMedia('(display-mode: standalone)').matches) {
      setShowInstall(false);
    }

    return () => window.removeEventListener('beforeinstallprompt', handler);
  }, []);

  const handleInstall = async () => {
    if (!deferredPrompt) return;

    deferredPrompt.prompt();
    const { outcome } = await deferredPrompt.userChoice;
    
    console.log(`User ${outcome === 'accepted' ? 'accepted' : 'dismissed'} install`);
    
    setDeferredPrompt(null);
    setShowInstall(false);
  };

  if (!showInstall) return null;

  return (
    <div className="install-banner">
      <p>Install our app for better experience!</p>
      <button onClick={handleInstall}>Install</button>
      <button onClick={() => setShowInstall(false)}>Dismiss</button>
    </div>
  );
}

3. Push Notifications Web Push Protocol

Component Purpose Implementation Best Practices
VAPID Keys Voluntary Application Server Identification. Public/private key pair for auth Generate: web-push generate-vapid-keys. Store private securely. Public in client Never expose private key. Rotate yearly. Store in env vars. Use same keys across envs
Push Subscription User permission, get subscription endpoint, send to server, store in DB registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey }) Request permission contextually. Explain value. Fallback if denied. Resubscribe on expire
Push Server Send notifications via web-push library. Payload max 4KB. Encryption automatic Node: webpush.sendNotification(subscription, payload). Python: pywebpush Batch sends for efficiency. Retry on failure. Remove invalid subscriptions. Rate limit
Notification Payload Title, body, icon, badge, image, actions, tag, data, silent notifications { title, body, icon, badge, image, actions: [{ action, title }], data } Title 50 chars, body 120 chars. Icon 192x192. Actions max 2. Tag for grouping. Rich image
Service Worker Handler Listen for push event. Show notification. Handle click. Open app or URL self.addEventListener('push', event). registration.showNotification() Always show notification (userVisibleOnly). Handle click. Track engagement. Badge count
User Experience Timely, relevant, precise. Opt-in, not spam. Allow disable. Track click-through Contextual permission prompt. Settings to manage. Unsubscribe easy. Analytics Max 1-2 per day. Personalized. Actionable. Test before sending. A/B test messages

Example: Client-side push subscription

// utils/pushNotifications.ts
const PUBLIC_VAPID_KEY = 'BNXz...'; // Your public VAPID key

export async function subscribeToPush(): Promise<PushSubscription | null> {
  try {
    // Check permission
    const permission = await Notification.requestPermission();
    if (permission !== 'granted') {
      console.log('Notification permission denied');
      return null;
    }

    // Get service worker registration
    const registration = await navigator.serviceWorker.ready;

    // Check existing subscription
    let subscription = await registration.pushManager.getSubscription();

    if (!subscription) {
      // Create new subscription
      subscription = await registration.pushManager.subscribe({
        userVisibleOnly: true,
        applicationServerKey: urlBase64ToUint8Array(PUBLIC_VAPID_KEY),
      });
    }

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

    return subscription;
  } catch (error) {
    console.error('Push subscription failed:', error);
    return null;
  }
}

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

// React component
export function NotificationButton() {
  const [isSubscribed, setIsSubscribed] = useState(false);

  const handleSubscribe = async () => {
    const subscription = await subscribeToPush();
    setIsSubscribed(!!subscription);
  };

  return (
    <button onClick={handleSubscribe} disabled={isSubscribed}>
      {isSubscribed ? 'Notifications Enabled' : 'Enable Notifications'}
    </button>
  );
}

Example: Service worker push handler and server

// service-worker.js - Handle push events
self.addEventListener('push', (event) => {
  const data = event.data?.json() ?? {};
  const { title, body, icon, badge, image, actions, url } = data;

  const options = {
    body,
    icon: icon || '/icons/icon-192x192.png',
    badge: badge || '/icons/badge-72x72.png',
    image,
    actions: actions || [],
    data: { url },
    tag: data.tag || 'notification',
    requireInteraction: false,
    vibrate: [200, 100, 200],
  };

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

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

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

  event.waitUntil(
    clients.matchAll({ type: 'window', includeUncontrolled: true })
      .then((clientList) => {
        // Focus existing window if available
        for (const client of clientList) {
          if (client.url === urlToOpen && 'focus' in client) {
            return client.focus();
          }
        }
        // Open new window
        if (clients.openWindow) {
          return clients.openWindow(urlToOpen);
        }
      })
  );
});

// server.js - Send push notification
import webpush from 'web-push';

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

// Store subscriptions in database
const subscriptions = new Map();

app.post('/api/push/subscribe', (req, res) => {
  const subscription = req.body;
  subscriptions.set(subscription.endpoint, subscription);
  res.status(201).json({ success: true });
});

// Send notification
app.post('/api/push/send', async (req, res) => {
  const { title, body, url } = req.body;
  
  const payload = JSON.stringify({ title, body, url });
  
  const promises = Array.from(subscriptions.values()).map((subscription) =>
    webpush.sendNotification(subscription, payload)
      .catch((error) => {
        if (error.statusCode === 410) {
          // Subscription expired, remove it
          subscriptions.delete(subscription.endpoint);
        }
        console.error('Send failed:', error);
      })
  );

  await Promise.all(promises);
  res.json({ sent: promises.length });
});

4. Background Sync Offline Queue

Feature Use Cases Implementation Best Practices
Background Sync API Retry failed requests, Offline form submissions, Send analytics, Upload files when online registration.sync.register('sync-tag'). Service worker listens to sync event Use for non-urgent data. Retry failed requests. Queue operations. Expire after 24h
Workbox Background Sync Automatic retry queue, Exponential backoff, Replay requests, Plugin system new BackgroundSyncPlugin('queue-name', { maxRetentionTime: 24 * 60 }) Use workbox for simplicity. Configure retry logic. Max retention 24h. Monitor queue size
Offline Queue Store failed requests, Retry when online, Show pending operations, User feedback IndexedDB for queue. Online/offline events. Retry on network restore. UI indicators Show pending badge. Allow user to cancel. Preserve order. Handle conflicts. Sync on restore
Periodic Background Sync Fetch fresh content, Update cache, Background uploads, Data synchronization registration.periodicSync.register('tag', { minInterval: 24 * 60 * 60 * 1000 }) Min 12h interval. User engagement required. Battery-conscious. Update content proactively
Conflict Resolution Handle stale data, Merge changes, Last-write-wins, Operational transforms Version vectors, timestamps, CRDTs for complex merges. Server validates Timestamp all changes. Server arbitrates conflicts. User chooses on conflict. Preserve both
User Feedback Show sync status, Pending operations count, Retry notifications, Error handling Badge for pending. Toast on sync. Retry button. Show errors with details Visual feedback essential. Allow manual retry. Show progress. Clear success/failure

Example: Background sync with Workbox

// service-worker.js
import { BackgroundSyncPlugin } from 'workbox-background-sync';
import { registerRoute } from 'workbox-routing';
import { NetworkOnly } from 'workbox-strategies';

// Create background sync plugin
const bgSyncPlugin = new BackgroundSyncPlugin('api-queue', {
  maxRetentionTime: 24 * 60, // 24 hours in minutes
  onSync: async ({ queue }) => {
    let entry;
    while ((entry = await queue.shiftRequest())) {
      try {
        await fetch(entry.request.clone());
        console.log('Replay successful:', entry.request.url);
      } catch (error) {
        console.error('Replay failed:', error);
        await queue.unshiftRequest(entry);
        throw error;
      }
    }
  },
});

// Register route for API mutations
registerRoute(
  ({ url, request }) => 
    url.pathname.startsWith('/api/') &&
    ['POST', 'PUT', 'DELETE', 'PATCH'].includes(request.method),
  new NetworkOnly({
    plugins: [bgSyncPlugin],
  }),
  'POST' // Method
);

// Listen to sync event (native Background Sync API)
self.addEventListener('sync', (event) => {
  if (event.tag === 'sync-posts') {
    event.waitUntil(syncPosts());
  }
});

async function syncPosts() {
  const db = await openDatabase();
  const pendingPosts = await db.getAll('pending-posts');
  
  for (const post of pendingPosts) {
    try {
      const response = await fetch('/api/posts', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(post.data),
      });
      
      if (response.ok) {
        await db.delete('pending-posts', post.id);
      }
    } catch (error) {
      console.error('Sync failed:', error);
    }
  }
}

Example: Client-side offline queue manager

// offlineQueue.ts
import { openDB, DBSchema } from 'idb';

interface QueueItem {
  id: string;
  url: string;
  method: string;
  headers: Record<string, string>;
  body: string;
  timestamp: number;
  retries: number;
}

interface QueueDB extends DBSchema {
  'queue': {
    key: string;
    value: QueueItem;
    indexes: { 'timestamp': number };
  };
}

class OfflineQueue {
  private db!: IDBPDatabase<QueueDB>;

  async init() {
    this.db = await openDB<QueueDB>('offline-queue', 1, {
      upgrade(db) {
        const store = db.createObjectStore('queue', { keyPath: 'id' });
        store.createIndex('timestamp', 'timestamp');
      },
    });

    // Listen for online event
    window.addEventListener('online', () => this.processQueue());
  }

  async add(url: string, options: RequestInit): Promise<void> {
    const item: QueueItem = {
      id: crypto.randomUUID(),
      url,
      method: options.method || 'GET',
      headers: options.headers as Record<string, string>,
      body: options.body as string,
      timestamp: Date.now(),
      retries: 0,
    };

    await this.db.add('queue', item);
    
    // Try to process immediately
    if (navigator.onLine) {
      this.processQueue();
    }
  }

  async processQueue(): Promise<void> {
    const items = await this.db.getAll('queue');
    
    for (const item of items) {
      try {
        const response = await fetch(item.url, {
          method: item.method,
          headers: item.headers,
          body: item.body,
        });

        if (response.ok) {
          await this.db.delete('queue', item.id);
          console.log('Synced:', item.url);
        } else if (response.status >= 400 && response.status < 500) {
          // Client error, don't retry
          await this.db.delete('queue', item.id);
        } else {
          // Server error, retry
          item.retries++;
          if (item.retries > 5) {
            await this.db.delete('queue', item.id);
          } else {
            await this.db.put('queue', item);
          }
        }
      } catch (error) {
        console.error('Queue processing failed:', error);
      }
    }
  }

  async getPendingCount(): Promise<number> {
    return (await this.db.getAll('queue')).length;
  }
}

export const offlineQueue = new OfflineQueue();

// Usage in React
export function useOfflineQueue() {
  const [pendingCount, setPendingCount] = useState(0);

  useEffect(() => {
    const updateCount = async () => {
      const count = await offlineQueue.getPendingCount();
      setPendingCount(count);
    };

    updateCount();
    const interval = setInterval(updateCount, 5000);
    return () => clearInterval(interval);
  }, []);

  return { pendingCount };
}

5. Cache API Storage Management

Feature Purpose API & Usage Best Practices
Cache Storage API Store Request/Response pairs, Multiple named caches, Version control, Programmatic control caches.open(name), .put(), .match(), .delete() Version cache names. Clean old versions. Use separate caches by type. Max 50MB per origin
Cache Versioning Update strategies, Migrate data, Remove old caches, Prevent conflicts Cache names: app-v1, app-v2. Delete old on activate Version in name. Delete old on SW activate. Atomic updates. Test migration path
Storage Quota Check available space, Request persistent storage, Monitor usage, Eviction policy navigator.storage.estimate(), .persist(), quota exceeded events Check quota before caching. Request persist for critical data. Handle quota errors gracefully
IndexedDB Structured data storage, Large datasets, Complex queries, Transactions, Indexes idb library for promises. Stores, indexes, cursors. 50MB+ storage Use idb wrapper. Version schema. Index frequently queried. Transaction for consistency
LocalStorage & SessionStorage Simple key-value, Synchronous, 5-10MB limit, String only, Tab/session scope localStorage.setItem(key, value), .getItem(). JSON stringify objects Use for small data. Not for sensitive data. Synchronous blocks. Prefer IndexedDB/Cache
Storage Management Clear old data, Handle quota errors, Prioritize important data, Eviction strategy LRU eviction. Timestamp entries. Clear on version update. Monitor usage Set expiration. Clear stale data weekly. Prioritize app shell. User control to clear

Example: Cache management in service worker

// service-worker.js
const CACHE_VERSION = 'v3';
const CACHE_NAMES = {
  static: `static-${CACHE_VERSION}`,
  dynamic: `dynamic-${CACHE_VERSION}`,
  images: `images-${CACHE_VERSION}`,
};

const STATIC_ASSETS = [
  '/',
  '/index.html',
  '/styles.css',
  '/app.js',
  '/manifest.json',
  '/offline.html',
];

// Install - precache static assets
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAMES.static)
      .then((cache) => cache.addAll(STATIC_ASSETS))
      .then(() => self.skipWaiting())
  );
});

// Activate - clean up old caches
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames
          .filter((name) => !Object.values(CACHE_NAMES).includes(name))
          .map((name) => {
            console.log('Deleting old cache:', name);
            return caches.delete(name);
          })
      );
    }).then(() => self.clients.claim())
  );
});

// Check and manage storage quota
async function checkStorageQuota() {
  if ('storage' in navigator && 'estimate' in navigator.storage) {
    const { usage, quota } = await navigator.storage.estimate();
    const percentUsed = (usage / quota) * 100;
    
    console.log(`Storage: ${usage} / ${quota} bytes (${percentUsed.toFixed(2)}%)`);
    
    if (percentUsed > 80) {
      // Clean up old caches
      await cleanupOldEntries();
    }
  }
}

async function cleanupOldEntries() {
  const cache = await caches.open(CACHE_NAMES.dynamic);
  const requests = await cache.keys();
  
  // Delete oldest 25% of entries
  const toDelete = Math.floor(requests.length * 0.25);
  for (let i = 0; i < toDelete; i++) {
    await cache.delete(requests[i]);
  }
}

Example: Storage quota monitoring

// storageManager.ts
export class StorageManager {
  async getStorageInfo() {
    if (!('storage' in navigator)) {
      return { usage: 0, quota: 0, percentage: 0, isPersisted: false };
    }

    const estimate = await navigator.storage.estimate();
    const usage = estimate.usage || 0;
    const quota = estimate.quota || 0;
    const percentage = (usage / quota) * 100;
    const isPersisted = await navigator.storage.persisted();

    return { usage, quota, percentage, isPersisted };
  }

  async requestPersistentStorage(): Promise<boolean> {
    if ('storage' in navigator && 'persist' in navigator.storage) {
      return await navigator.storage.persist();
    }
    return false;
  }

  formatBytes(bytes: number): string {
    if (bytes === 0) return '0 Bytes';
    const k = 1024;
    const sizes = ['Bytes', 'KB', 'MB', 'GB'];
    const i = Math.floor(Math.log(bytes) / Math.log(k));
    return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
  }

  async clearAllStorage() {
    // Clear all caches
    const cacheNames = await caches.keys();
    await Promise.all(cacheNames.map(name => caches.delete(name)));

    // Clear IndexedDB
    const databases = await indexedDB.databases();
    databases.forEach(db => indexedDB.deleteDatabase(db.name!));

    // Clear localStorage
    localStorage.clear();
    sessionStorage.clear();

    console.log('All storage cleared');
  }
}

// React component to show storage info
export function StorageMonitor() {
  const [info, setInfo] = useState<any>(null);

  useEffect(() => {
    const manager = new StorageManager();
    
    const update = async () => {
      const storageInfo = await manager.getStorageInfo();
      setInfo(storageInfo);
    };

    update();
    const interval = setInterval(update, 30000); // Update every 30s
    return () => clearInterval(interval);
  }, []);

  if (!info) return null;

  const manager = new StorageManager();

  return (
    <div className="storage-monitor">
      <h3>Storage Usage</h3>
      <p>{manager.formatBytes(info.usage)} / {manager.formatBytes(info.quota)}</p>
      <p>{info.percentage.toFixed(2)}% used</p>
      <p>Persistent: {info.isPersisted ? 'Yes' : 'No'}</p>
      {info.percentage > 80 && (
        <p className="warning">Storage almost full!</p>
      )}
    </div>
  );
}

6. App Shell Architecture Performance

Concept Description Implementation Performance Benefits
App Shell Pattern Minimal HTML/CSS/JS for UI shell. Cache shell, load content dynamically. Instant load Precache shell assets. Critical CSS inline. Lazy load content. Route-based code split Sub-1s load. FCP <1s. TTI <3s. 90+ Lighthouse score. Perceived performance boost
Critical Rendering Path Inline critical CSS, Defer non-critical JS, Async fonts, Preload key resources <style> critical CSS. <link rel="preload">. async/defer scripts. font-display: swap FCP <1s. No render-blocking. Progressive rendering. Fast first paint. Better UX
Code Splitting Split by route, feature, vendor. Lazy load on demand. Reduce initial bundle React.lazy, dynamic import(), route-based splitting. Webpack chunks. Vite code split Initial bundle <200KB. 50-70% reduction. Faster TTI. Load only what's needed
Resource Hints Preload, prefetch, preconnect, dns-prefetch. Guide browser optimization <link rel="preload|prefetch|preconnect|dns-prefetch" href="..." /> Preload critical (fonts, CSS). Prefetch next page. Preconnect APIs. Faster resource load
Skeleton Screens Show UI structure while loading. Better perceived performance than spinners CSS placeholder. Animate pulse. Match real content layout. Replace with real data Perceived load 30% faster. Reduce bounce. Professional UX. No jarring layout shifts
Performance Budget Set limits: bundle size, load time, metrics. Enforce in CI. Track over time Lighthouse CI. webpack-bundle-analyzer. size-limit. Fail build if exceeded JS <200KB. FCP <1s. TTI <3s. LCP <2.5s. Maintain performance discipline

Example: App shell HTML structure

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>PWA App Shell</title>
  
  <!-- Critical CSS inlined -->
  <style>
    /* App shell critical styles - inline for instant render */
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body { font-family: -apple-system, sans-serif; }
    .shell-header { height: 56px; background: #1976d2; color: white; }
    .shell-nav { width: 200px; background: #f5f5f5; }
    .shell-content { flex: 1; }
    .skeleton { background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%); }
  </style>
  
  <!-- Resource hints -->
  <link rel="preconnect" href="https://api.example.com">
  <link rel="dns-prefetch" href="https://cdn.example.com">
  <link rel="preload" href="/fonts/main.woff2" as="font" type="font/woff2" crossorigin>
  <link rel="preload" href="/app.js" as="script">
  
  <!-- Non-critical CSS -->
  <link rel="stylesheet" href="/styles.css" media="print" onload="this.media='all'">
  
  <link rel="manifest" href="/manifest.json">
  <meta name="theme-color" content="#1976d2">
</head>
<body>
  <!-- App Shell - loads instantly from cache -->
  <div id="app-shell">
    <header class="shell-header">
      <h1>My PWA</h1>
    </header>
    
    <div class="shell-layout">
      <nav class="shell-nav">
        <!-- Navigation skeleton -->
        <div class="skeleton" style="height: 40px; margin: 10px;"></div>
        <div class="skeleton" style="height: 40px; margin: 10px;"></div>
        <div class="skeleton" style="height: 40px; margin: 10px;"></div>
      </nav>
      
      <main class="shell-content">
        <!-- Content skeleton -->
        <div class="skeleton" style="height: 200px; margin: 20px;"></div>
        <div class="skeleton" style="height: 100px; margin: 20px;"></div>
      </main>
    </div>
  </div>
  
  <!-- React root - content loaded dynamically -->
  <div id="root"></div>
  
  <!-- Defer non-critical JavaScript -->
  <script src="/app.js" defer></script>
  <script>
    // Register service worker
    if ('serviceWorker' in navigator) {
      window.addEventListener('load', () => {
        navigator.serviceWorker.register('/service-worker.js');
      });
    }
  </script>
</body>
</html>

Example: React app shell with code splitting

// App.tsx - Route-based code splitting
import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { AppShell } from './components/AppShell';
import { SkeletonLoader } from './components/SkeletonLoader';

// Lazy load route components
const Home = lazy(() => import('./pages/Home'));
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Profile = lazy(() => import('./pages/Profile'));
const Settings = lazy(() => import('./pages/Settings'));

export function App() {
  return (
    <BrowserRouter>
      <AppShell>
        <Suspense fallback={<SkeletonLoader />}>
          <Routes>
            <Route path="/" element={<Home />} />
            <Route path="/dashboard" element={<Dashboard />} />
            <Route path="/profile" element={<Profile />} />
            <Route path="/settings" element={<Settings />} />
          </Routes>
        </Suspense>
      </AppShell>
    </BrowserRouter>
  );
}

// components/AppShell.tsx - Persistent shell
export function AppShell({ children }: { children: React.ReactNode }) {
  return (
    <>
      <Header />
      <div className="layout">
        <Navigation />
        <main className="content">{children}</main>
      </div>
      <Footer />
    </>
  );
}

// components/SkeletonLoader.tsx
export function SkeletonLoader() {
  return (
    <div className="skeleton-container">
      <div className="skeleton skeleton-title" />
      <div className="skeleton skeleton-text" />
      <div className="skeleton skeleton-text" />
      <div className="skeleton skeleton-card" />
    </div>
  );
}

// vite.config.ts - Manual chunks for optimal splitting
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          'react-vendor': ['react', 'react-dom', 'react-router-dom'],
          'ui-components': ['./src/components/Button', './src/components/Input'],
        },
      },
    },
  },
});
PWA Performance Checklist:
  • First Load: FCP <1s, LCP <2.5s, TTI <3s. Lighthouse score 90+
  • Bundle Size: Initial JS <200KB. Total <500KB. Use code splitting
  • Caching: Cache app shell. Stale-while-revalidate for content. 50MB cache limit
  • Offline: All routes work offline. Background sync for mutations. Show network status
  • Install: Manifest.json complete. Icons 192x192, 512x512. Install prompt UX

Progressive Web App Summary

  • Service Workers: Workbox for caching. CacheFirst for static. NetworkFirst for API. Offline fallback
  • Manifest: Complete manifest.json. Icons all sizes. Maskable icons. Install prompt. theme_color
  • Push Notifications: VAPID keys. User permission. Payload <4KB. Handle click. 1-2 per day max
  • Background Sync: Queue failed requests. Workbox BackgroundSyncPlugin. Retry on online. User feedback
  • Storage: Cache API for responses. IndexedDB for data. Monitor quota. LRU eviction. Persistent storage
  • App Shell: Instant load shell. Inline critical CSS. Code split routes. Skeleton screens. Sub-1s FCP
PWA Production Standards: Achieve Lighthouse PWA score 100, offline-capable, installable, sub-3s TTI, push notifications (opt-in), and background sync for resilience. PWA = native-like experience on web.