JavaScript APIsFeatured

JavaScript Progressive Web Apps: Building Native-Like Experiences

Master Progressive Web Apps with JavaScript. Learn service workers, caching strategies, offline functionality, and native app features for modern web experiences.

By JavaScript Document Team
pwaservice-workersofflineweb-manifestpush-notifications

Progressive Web Apps (PWAs) combine the best of web and native apps, offering app-like experiences with web technologies. They provide offline functionality, push notifications, and native app features while remaining accessible through web browsers.

Understanding Progressive Web Apps

PWAs are web applications that use modern web capabilities to deliver app-like experiences. They're reliable, fast, and engaging, working offline and providing native app features.

Core PWA Technologies

// Check PWA support
function checkPWASupport() {
  const support = {
    serviceWorker: 'serviceWorker' in navigator,
    manifest: 'manifest' in document.createElement('link'),
    notification: 'Notification' in window,
    pushManager: 'PushManager' in window,
    cacheAPI: 'caches' in window,
  };

  console.log('PWA Support:', support);
  return support;
}

// PWA installation detection
class PWAInstaller {
  constructor() {
    this.deferredPrompt = null;
    this.isInstalled = false;

    this.init();
  }

  init() {
    // Listen for beforeinstallprompt event
    window.addEventListener('beforeinstallprompt', (event) => {
      event.preventDefault();
      this.deferredPrompt = event;
      this.showInstallButton();
    });

    // Check if already installed
    window.addEventListener('appinstalled', () => {
      this.isInstalled = true;
      this.hideInstallButton();
      console.log('PWA installed successfully');
    });

    // Check if launched as PWA
    if (
      window.navigator.standalone ||
      window.matchMedia('(display-mode: standalone)').matches
    ) {
      this.isInstalled = true;
    }
  }

  async promptInstall() {
    if (!this.deferredPrompt) {
      console.log('Install prompt not available');
      return false;
    }

    this.deferredPrompt.prompt();

    const { outcome } = await this.deferredPrompt.userChoice;

    if (outcome === 'accepted') {
      console.log('User accepted install prompt');
    } else {
      console.log('User dismissed install prompt');
    }

    this.deferredPrompt = null;
    return outcome === 'accepted';
  }

  showInstallButton() {
    const installButton = document.getElementById('install-button');
    if (installButton) {
      installButton.style.display = 'block';
      installButton.addEventListener('click', () => this.promptInstall());
    }
  }

  hideInstallButton() {
    const installButton = document.getElementById('install-button');
    if (installButton) {
      installButton.style.display = 'none';
    }
  }

  getInstallStatus() {
    return {
      isInstallable: !!this.deferredPrompt,
      isInstalled: this.isInstalled,
      isStandalone:
        window.navigator.standalone ||
        window.matchMedia('(display-mode: standalone)').matches,
    };
  }
}

Service Workers

Basic Service Worker Setup

// main.js - Register service worker
async function registerServiceWorker() {
  if ('serviceWorker' in navigator) {
    try {
      const registration = await navigator.serviceWorker.register('/sw.js', {
        scope: '/',
      });

      console.log('Service Worker registered:', registration);

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

        newWorker.addEventListener('statechange', () => {
          if (
            newWorker.state === 'installed' &&
            navigator.serviceWorker.controller
          ) {
            // New version available
            showUpdateAvailable();
          }
        });
      });

      return registration;
    } catch (error) {
      console.error('Service Worker registration failed:', error);
    }
  }
}

// sw.js - Service Worker
const CACHE_NAME = 'pwa-cache-v1';
const STATIC_CACHE = 'static-v1';
const DYNAMIC_CACHE = 'dynamic-v1';

const STATIC_FILES = [
  '/',
  '/index.html',
  '/styles.css',
  '/app.js',
  '/manifest.json',
  '/icons/icon-192.png',
  '/icons/icon-512.png',
];

// Install event - cache static resources
self.addEventListener('install', (event) => {
  console.log('Service Worker installing...');

  event.waitUntil(
    caches
      .open(STATIC_CACHE)
      .then((cache) => {
        console.log('Caching static files');
        return cache.addAll(STATIC_FILES);
      })
      .then(() => {
        console.log('Static files cached');
        return self.skipWaiting(); // Activate immediately
      })
  );
});

// Activate event - clean up old caches
self.addEventListener('activate', (event) => {
  console.log('Service Worker activating...');

  event.waitUntil(
    caches
      .keys()
      .then((cacheNames) => {
        return Promise.all(
          cacheNames.map((cacheName) => {
            if (cacheName !== STATIC_CACHE && cacheName !== DYNAMIC_CACHE) {
              console.log('Deleting old cache:', cacheName);
              return caches.delete(cacheName);
            }
          })
        );
      })
      .then(() => {
        console.log('Service Worker activated');
        return self.clients.claim(); // Take control immediately
      })
  );
});

// Fetch event - serve cached content
self.addEventListener('fetch', (event) => {
  const { request } = event;

  // Skip non-GET requests
  if (request.method !== 'GET') {
    return;
  }

  event.respondWith(
    caches
      .match(request)
      .then((cachedResponse) => {
        if (cachedResponse) {
          return cachedResponse;
        }

        // Clone request for cache
        const fetchRequest = request.clone();

        return fetch(fetchRequest).then((response) => {
          if (
            !response ||
            response.status !== 200 ||
            response.type !== 'basic'
          ) {
            return response;
          }

          // Clone response for cache
          const responseToCache = response.clone();

          caches.open(DYNAMIC_CACHE).then((cache) => {
            cache.put(request, responseToCache);
          });

          return response;
        });
      })
      .catch(() => {
        // Return offline page for navigation requests
        if (request.destination === 'document') {
          return caches.match('/offline.html');
        }
      })
  );
});

Advanced Caching Strategies

// Advanced Service Worker with multiple caching strategies
class ServiceWorkerManager {
  constructor() {
    this.CACHE_NAME = 'pwa-advanced-v1';
    this.STATIC_CACHE = 'static-v1';
    this.API_CACHE = 'api-v1';
    this.IMAGE_CACHE = 'images-v1';

    this.strategies = {
      cacheFirst: this.cacheFirst.bind(this),
      networkFirst: this.networkFirst.bind(this),
      staleWhileRevalidate: this.staleWhileRevalidate.bind(this),
      networkOnly: this.networkOnly.bind(this),
      cacheOnly: this.cacheOnly.bind(this),
    };

    this.routes = [
      {
        pattern: /\.(?:png|jpg|jpeg|svg|gif|webp)$/,
        strategy: 'cacheFirst',
        cache: this.IMAGE_CACHE,
        maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
      },
      {
        pattern: /\/api\//,
        strategy: 'networkFirst',
        cache: this.API_CACHE,
        maxAge: 5 * 60 * 1000, // 5 minutes
      },
      {
        pattern: /\.(?:js|css)$/,
        strategy: 'staleWhileRevalidate',
        cache: this.STATIC_CACHE,
      },
      {
        pattern: /\//,
        strategy: 'networkFirst',
        cache: this.STATIC_CACHE,
      },
    ];
  }

  // Cache First - serve from cache, fallback to network
  async cacheFirst(request, cacheName) {
    const cache = await caches.open(cacheName);
    const cachedResponse = await cache.match(request);

    if (cachedResponse) {
      return cachedResponse;
    }

    try {
      const networkResponse = await fetch(request);
      if (networkResponse.status === 200) {
        cache.put(request, networkResponse.clone());
      }
      return networkResponse;
    } catch (error) {
      throw error;
    }
  }

  // Network First - try network, fallback to cache
  async networkFirst(request, cacheName) {
    const cache = await caches.open(cacheName);

    try {
      const networkResponse = await fetch(request);
      if (networkResponse.status === 200) {
        cache.put(request, networkResponse.clone());
      }
      return networkResponse;
    } catch (error) {
      const cachedResponse = await cache.match(request);
      if (cachedResponse) {
        return cachedResponse;
      }
      throw error;
    }
  }

  // Stale While Revalidate - serve from cache, update in background
  async staleWhileRevalidate(request, cacheName) {
    const cache = await caches.open(cacheName);
    const cachedResponse = await cache.match(request);

    const networkPromise = fetch(request)
      .then((response) => {
        if (response.status === 200) {
          cache.put(request, response.clone());
        }
        return response;
      })
      .catch(() => cachedResponse);

    return cachedResponse || networkPromise;
  }

  // Network Only - always fetch from network
  async networkOnly(request) {
    return fetch(request);
  }

  // Cache Only - only serve from cache
  async cacheOnly(request, cacheName) {
    const cache = await caches.open(cacheName);
    return cache.match(request);
  }

  // Route request to appropriate strategy
  handleRequest(request) {
    const url = new URL(request.url);

    for (const route of this.routes) {
      if (route.pattern.test(url.pathname)) {
        const strategy = this.strategies[route.strategy];
        return strategy(request, route.cache);
      }
    }

    // Default to network first
    return this.networkFirst(request, this.STATIC_CACHE);
  }

  // Clean expired cache entries
  async cleanExpiredCache() {
    const cacheNames = await caches.keys();

    for (const cacheName of cacheNames) {
      const cache = await caches.open(cacheName);
      const requests = await cache.keys();

      for (const request of requests) {
        const response = await cache.match(request);
        const dateHeader = response.headers.get('date');

        if (dateHeader) {
          const cachedTime = new Date(dateHeader).getTime();
          const now = Date.now();
          const maxAge = this.getMaxAgeForCache(cacheName);

          if (now - cachedTime > maxAge) {
            await cache.delete(request);
            console.log('Deleted expired cache entry:', request.url);
          }
        }
      }
    }
  }

  getMaxAgeForCache(cacheName) {
    const route = this.routes.find((r) => r.cache === cacheName);
    return route?.maxAge || 24 * 60 * 60 * 1000; // Default 1 day
  }
}

// Use in service worker
const swManager = new ServiceWorkerManager();

self.addEventListener('fetch', (event) => {
  event.respondWith(swManager.handleRequest(event.request));
});

// Clean expired cache periodically
self.addEventListener('message', (event) => {
  if (event.data && event.data.type === 'CLEAN_CACHE') {
    swManager.cleanExpiredCache();
  }
});

Offline Functionality

Offline Storage Management

class OfflineManager {
  constructor() {
    this.dbName = 'PWAOfflineDB';
    this.dbVersion = 1;
    this.db = null;

    this.init();
  }

  async init() {
    this.db = await this.openDB();
    this.setupOnlineOfflineListeners();
  }

  openDB() {
    return new Promise((resolve, reject) => {
      const request = indexedDB.open(this.dbName, this.dbVersion);

      request.onerror = () => reject(request.error);
      request.onsuccess = () => resolve(request.result);

      request.onupgradeneeded = (event) => {
        const db = event.target.result;

        // Create stores
        if (!db.objectStoreNames.contains('pending_requests')) {
          const pendingStore = db.createObjectStore('pending_requests', {
            keyPath: 'id',
            autoIncrement: true,
          });
          pendingStore.createIndex('url', 'url', { unique: false });
          pendingStore.createIndex('timestamp', 'timestamp', { unique: false });
        }

        if (!db.objectStoreNames.contains('offline_data')) {
          const dataStore = db.createObjectStore('offline_data', {
            keyPath: 'key',
          });
        }
      };
    });
  }

  setupOnlineOfflineListeners() {
    window.addEventListener('online', () => {
      console.log('Back online');
      this.processPendingRequests();
      this.notifyOnlineStatus(true);
    });

    window.addEventListener('offline', () => {
      console.log('Gone offline');
      this.notifyOnlineStatus(false);
    });
  }

  // Store failed requests for later retry
  async storePendingRequest(url, method, data, headers = {}) {
    const transaction = this.db.transaction(['pending_requests'], 'readwrite');
    const store = transaction.objectStore('pending_requests');

    const request = {
      url,
      method,
      data,
      headers,
      timestamp: Date.now(),
    };

    await store.add(request);
    console.log('Stored pending request:', url);
  }

  // Process all pending requests when back online
  async processPendingRequests() {
    if (!navigator.onLine) return;

    const transaction = this.db.transaction(['pending_requests'], 'readwrite');
    const store = transaction.objectStore('pending_requests');
    const requests = await store.getAll();

    for (const request of requests) {
      try {
        const response = await fetch(request.url, {
          method: request.method,
          headers: request.headers,
          body: request.data,
        });

        if (response.ok) {
          await store.delete(request.id);
          console.log('Successfully processed pending request:', request.url);
        }
      } catch (error) {
        console.error('Failed to process pending request:', error);
      }
    }
  }

  // Store data for offline access
  async storeOfflineData(key, data) {
    const transaction = this.db.transaction(['offline_data'], 'readwrite');
    const store = transaction.objectStore('offline_data');

    await store.put({
      key,
      data,
      timestamp: Date.now(),
    });
  }

  // Retrieve offline data
  async getOfflineData(key) {
    const transaction = this.db.transaction(['offline_data'], 'readonly');
    const store = transaction.objectStore('offline_data');

    const result = await store.get(key);
    return result?.data;
  }

  // Check if online
  isOnline() {
    return navigator.onLine;
  }

  // Notify app of online status changes
  notifyOnlineStatus(isOnline) {
    const event = new CustomEvent('onlineStatusChanged', {
      detail: { isOnline },
    });
    window.dispatchEvent(event);
  }

  // Enhanced fetch with offline support
  async fetchWithOfflineSupport(url, options = {}) {
    try {
      const response = await fetch(url, options);

      if (response.ok) {
        // Store successful response for offline access
        if (options.method === 'GET' || !options.method) {
          const data = await response.clone().json();
          await this.storeOfflineData(url, data);
        }
      }

      return response;
    } catch (error) {
      // If offline or network error
      if (!this.isOnline() || error.name === 'TypeError') {
        // For GET requests, try to serve from offline storage
        if (options.method === 'GET' || !options.method) {
          const offlineData = await this.getOfflineData(url);
          if (offlineData) {
            return new Response(JSON.stringify(offlineData), {
              status: 200,
              headers: { 'Content-Type': 'application/json' },
            });
          }
        } else {
          // For other methods, store for later retry
          await this.storePendingRequest(
            url,
            options.method,
            options.body,
            options.headers
          );
        }
      }

      throw error;
    }
  }
}

Background Sync

// Background Sync for reliable data synchronization
class BackgroundSyncManager {
  constructor() {
    this.syncTags = {
      DATA_SYNC: 'data-sync',
      MESSAGE_SYNC: 'message-sync',
      FILE_SYNC: 'file-sync',
    };
  }

  // Register background sync
  async registerSync(tag, data = {}) {
    if (
      'serviceWorker' in navigator &&
      'sync' in window.ServiceWorkerRegistration.prototype
    ) {
      try {
        const registration = await navigator.serviceWorker.ready;

        // Store sync data
        await this.storeSyncData(tag, data);

        // Register sync
        await registration.sync.register(tag);
        console.log(`Background sync registered: ${tag}`);

        return true;
      } catch (error) {
        console.error('Background sync registration failed:', error);

        // Fallback: try immediate sync
        await this.performSync(tag);
        return false;
      }
    } else {
      console.warn('Background sync not supported');
      return false;
    }
  }

  // Store sync data
  async storeSyncData(tag, data) {
    const syncData = {
      tag,
      data,
      timestamp: Date.now(),
      retryCount: 0,
    };

    localStorage.setItem(`sync_${tag}`, JSON.stringify(syncData));
  }

  // Retrieve sync data
  getSyncData(tag) {
    const data = localStorage.getItem(`sync_${tag}`);
    return data ? JSON.parse(data) : null;
  }

  // Remove sync data
  removeSyncData(tag) {
    localStorage.removeItem(`sync_${tag}`);
  }

  // Perform actual sync operation
  async performSync(tag) {
    const syncData = this.getSyncData(tag);

    if (!syncData) {
      console.warn(`No sync data found for tag: ${tag}`);
      return false;
    }

    try {
      let success = false;

      switch (tag) {
        case this.syncTags.DATA_SYNC:
          success = await this.syncData(syncData.data);
          break;
        case this.syncTags.MESSAGE_SYNC:
          success = await this.syncMessages(syncData.data);
          break;
        case this.syncTags.FILE_SYNC:
          success = await this.syncFiles(syncData.data);
          break;
        default:
          console.warn(`Unknown sync tag: ${tag}`);
      }

      if (success) {
        this.removeSyncData(tag);
        console.log(`Sync completed successfully: ${tag}`);
        return true;
      } else {
        // Increment retry count
        syncData.retryCount++;
        if (syncData.retryCount < 3) {
          this.storeSyncData(tag, syncData.data);
        } else {
          this.removeSyncData(tag);
          console.error(`Sync failed after 3 retries: ${tag}`);
        }
        return false;
      }
    } catch (error) {
      console.error(`Sync error for ${tag}:`, error);
      return false;
    }
  }

  // Sync data to server
  async syncData(data) {
    try {
      const response = await fetch('/api/sync', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(data),
      });

      return response.ok;
    } catch (error) {
      console.error('Data sync failed:', error);
      return false;
    }
  }

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

      return response.ok;
    } catch (error) {
      console.error('Message sync failed:', error);
      return false;
    }
  }

  // Sync files
  async syncFiles(files) {
    try {
      for (const file of files) {
        const formData = new FormData();
        formData.append('file', file.blob);
        formData.append('metadata', JSON.stringify(file.metadata));

        const response = await fetch('/api/files/upload', {
          method: 'POST',
          body: formData,
        });

        if (!response.ok) {
          return false;
        }
      }

      return true;
    } catch (error) {
      console.error('File sync failed:', error);
      return false;
    }
  }
}

// Service Worker background sync handler
self.addEventListener('sync', (event) => {
  console.log('Background sync triggered:', event.tag);

  event.waitUntil(
    (async () => {
      const syncManager = new BackgroundSyncManager();
      await syncManager.performSync(event.tag);
    })()
  );
});

Push Notifications

class PushNotificationManager {
  constructor() {
    this.vapidPublicKey = 'YOUR_VAPID_PUBLIC_KEY';
    this.subscription = null;
  }

  // Check notification support and permission
  async checkSupport() {
    if (!('Notification' in window)) {
      throw new Error('Notifications not supported');
    }

    if (!('PushManager' in window)) {
      throw new Error('Push messaging not supported');
    }

    const permission = await Notification.requestPermission();

    if (permission !== 'granted') {
      throw new Error('Notification permission denied');
    }

    return true;
  }

  // Subscribe to push notifications
  async subscribe() {
    try {
      await this.checkSupport();

      const registration = await navigator.serviceWorker.ready;

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

      // Send subscription to server
      await this.sendSubscriptionToServer(this.subscription);

      console.log('Push subscription successful');
      return this.subscription;
    } catch (error) {
      console.error('Push subscription failed:', error);
      throw error;
    }
  }

  // Unsubscribe from push notifications
  async unsubscribe() {
    if (this.subscription) {
      await this.subscription.unsubscribe();
      await this.removeSubscriptionFromServer(this.subscription);
      this.subscription = null;
      console.log('Push unsubscription successful');
    }
  }

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

      if (!response.ok) {
        throw new Error('Failed to send subscription to server');
      }
    } catch (error) {
      console.error('Error sending subscription to server:', error);
      throw error;
    }
  }

  // Remove subscription from server
  async removeSubscriptionFromServer(subscription) {
    try {
      await fetch('/api/push/unsubscribe', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(subscription),
      });
    } catch (error) {
      console.error('Error removing subscription from server:', error);
    }
  }

  // Utility function to convert VAPID key
  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;
  }

  // Check current subscription status
  async getSubscriptionStatus() {
    try {
      const registration = await navigator.serviceWorker.ready;
      this.subscription = await registration.pushManager.getSubscription();

      return {
        isSubscribed: !!this.subscription,
        subscription: this.subscription,
      };
    } catch (error) {
      console.error('Error getting subscription status:', error);
      return { isSubscribed: false, subscription: null };
    }
  }
}

// Service Worker push event handler
self.addEventListener('push', (event) => {
  let notificationData = {
    title: 'New Notification',
    body: 'You have a new message',
    icon: '/icons/icon-192.png',
    badge: '/icons/badge-72.png',
    tag: 'default',
    data: {},
  };

  if (event.data) {
    try {
      notificationData = {
        ...notificationData,
        ...event.data.json(),
      };
    } catch (error) {
      console.error('Error parsing push data:', error);
    }
  }

  const notificationOptions = {
    body: notificationData.body,
    icon: notificationData.icon,
    badge: notificationData.badge,
    tag: notificationData.tag,
    data: notificationData.data,
    actions: notificationData.actions || [],
    requireInteraction: notificationData.requireInteraction || false,
    silent: notificationData.silent || false,
  };

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

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

  const action = event.action;
  const data = event.notification.data;

  event.waitUntil(
    (async () => {
      const clients = await self.clients.matchAll({ type: 'window' });

      if (action === 'open_app' || !action) {
        // Open app or focus existing window
        if (clients.length > 0) {
          return clients[0].focus();
        } else {
          return self.clients.openWindow('/');
        }
      } else if (action === 'dismiss') {
        // Just close notification
        return;
      } else {
        // Custom action
        return self.clients.openWindow(data.url || '/');
      }
    })()
  );
});

Web App Manifest

// manifest.json
{
  "name": "My Progressive Web App",
  "short_name": "MyPWA",
  "description": "A feature-rich Progressive Web App",
  "start_url": "/",
  "display": "standalone",
  "orientation": "portrait-primary",
  "theme_color": "#2196F3",
  "background_color": "#ffffff",
  "categories": ["productivity", "utilities"],
  "lang": "en",
  "dir": "ltr",
  "scope": "/",
  "icons": [
    {
      "src": "/icons/icon-72.png",
      "sizes": "72x72",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-96.png",
      "sizes": "96x96",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-128.png",
      "sizes": "128x128",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-144.png",
      "sizes": "144x144",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-152.png",
      "sizes": "152x152",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-384.png",
      "sizes": "384x384",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-512.png",
      "sizes": "512x512",
      "type": "image/png"
    }
  ],
  "shortcuts": [
    {
      "name": "Create New",
      "short_name": "New",
      "description": "Create a new item",
      "url": "/new",
      "icons": [
        {
          "src": "/icons/shortcut-new.png",
          "sizes": "192x192"
        }
      ]
    }
  ],
  "share_target": {
    "action": "/share",
    "method": "POST",
    "enctype": "multipart/form-data",
    "params": {
      "title": "title",
      "text": "text",
      "url": "url",
      "files": [
        {
          "name": "file",
          "accept": ["image/*", "text/*"]
        }
      ]
    }
  },
  "protocol_handlers": [
    {
      "protocol": "web+myapp",
      "url": "/handle?protocol=%s"
    }
  ]
}

// Manifest manager
class ManifestManager {
  constructor() {
    this.manifest = null;
  }

  async loadManifest() {
    try {
      const response = await fetch('/manifest.json');
      this.manifest = await response.json();
      return this.manifest;
    } catch (error) {
      console.error('Failed to load manifest:', error);
      return null;
    }
  }

  // Update manifest dynamically
  updateManifest(updates) {
    if (!this.manifest) return;

    Object.assign(this.manifest, updates);

    // Update the manifest link
    const manifestLink = document.querySelector('link[rel="manifest"]');
    if (manifestLink) {
      const blob = new Blob([JSON.stringify(this.manifest)], {
        type: 'application/json'
      });
      const url = URL.createObjectURL(blob);
      manifestLink.href = url;
    }
  }

  // Update theme color
  updateThemeColor(color) {
    this.updateManifest({ theme_color: color });

    // Update meta tag
    let metaTheme = document.querySelector('meta[name="theme-color"]');
    if (!metaTheme) {
      metaTheme = document.createElement('meta');
      metaTheme.name = 'theme-color';
      document.head.appendChild(metaTheme);
    }
    metaTheme.content = color;
  }

  // Get manifest data
  getManifest() {
    return this.manifest;
  }

  // Check if app meets PWA criteria
  checkPWACriteria() {
    const criteria = {
      hasManifest: !!document.querySelector('link[rel="manifest"]'),
      hasServiceWorker: 'serviceWorker' in navigator,
      hasIcons: this.manifest?.icons?.length > 0,
      hasStartUrl: !!this.manifest?.start_url,
      hasName: !!this.manifest?.name || !!this.manifest?.short_name,
      hasDisplay: !!this.manifest?.display,
      isServedOverHTTPS: location.protocol === 'https:' || location.hostname === 'localhost'
    };

    const passed = Object.values(criteria).filter(Boolean).length;
    const total = Object.keys(criteria).length;

    return {
      criteria,
      score: passed / total,
      isPWA: passed === total
    };
  }
}

Best Practices

  1. App Shell Architecture
class AppShell {
  constructor() {
    this.shell = document.getElementById('app-shell');
    this.content = document.getElementById('app-content');
    this.loading = document.getElementById('loading');
  }

  showLoading() {
    this.loading.style.display = 'flex';
    this.content.style.display = 'none';
  }

  hideLoading() {
    this.loading.style.display = 'none';
    this.content.style.display = 'block';
  }

  updateContent(html) {
    this.content.innerHTML = html;
  }

  // Preload critical resources
  preloadCriticalResources() {
    const criticalResources = [
      '/styles/critical.css',
      '/js/app.js',
      '/icons/icon-192.png',
    ];

    criticalResources.forEach((resource) => {
      const link = document.createElement('link');
      link.rel = 'preload';
      link.href = resource;
      link.as = this.getResourceType(resource);
      document.head.appendChild(link);
    });
  }

  getResourceType(url) {
    if (url.endsWith('.css')) return 'style';
    if (url.endsWith('.js')) return 'script';
    if (url.match(/\.(png|jpg|jpeg|gif|webp)$/)) return 'image';
    return 'fetch';
  }
}
  1. Performance Optimization
class PWAPerformance {
  constructor() {
    this.metrics = {};
  }

  // Measure performance
  measurePerformance() {
    if ('performance' in window) {
      const navigation = performance.getEntriesByType('navigation')[0];

      this.metrics = {
        dns: navigation.domainLookupEnd - navigation.domainLookupStart,
        connection: navigation.connectEnd - navigation.connectStart,
        request: navigation.responseStart - navigation.requestStart,
        response: navigation.responseEnd - navigation.responseStart,
        dom:
          navigation.domContentLoadedEventEnd -
          navigation.domContentLoadedEventStart,
        load: navigation.loadEventEnd - navigation.loadEventStart,
        total: navigation.loadEventEnd - navigation.navigationStart,
      };

      console.log('Performance metrics:', this.metrics);
      return this.metrics;
    }
  }

  // Monitor Core Web Vitals
  monitorWebVitals() {
    // Largest Contentful Paint
    new PerformanceObserver((entryList) => {
      const entries = entryList.getEntries();
      const lastEntry = entries[entries.length - 1];
      console.log('LCP:', lastEntry.startTime);
    }).observe({ entryTypes: ['largest-contentful-paint'] });

    // First Input Delay
    new PerformanceObserver((entryList) => {
      const entries = entryList.getEntries();
      entries.forEach((entry) => {
        console.log('FID:', entry.processingStart - entry.startTime);
      });
    }).observe({ entryTypes: ['first-input'] });

    // Cumulative Layout Shift
    let clsValue = 0;
    new PerformanceObserver((entryList) => {
      const entries = entryList.getEntries();
      entries.forEach((entry) => {
        if (!entry.hadRecentInput) {
          clsValue += entry.value;
        }
      });
      console.log('CLS:', clsValue);
    }).observe({ entryTypes: ['layout-shift'] });
  }
}

Conclusion

Progressive Web Apps represent the future of web development, combining the reach of the web with the capabilities of native apps. By implementing service workers, offline functionality, push notifications, and following PWA best practices, you can create engaging, reliable, and fast web applications that work seamlessly across all devices and network conditions. PWAs offer users app-like experiences while providing developers with web platform advantages like easy deployment and universal accessibility.