PWA Basics

Progressive Web App fundamentals, service workers, and manifest files

PWA Requirements

Web App Manifest

JSON file defining app metadata

manifest.json

Service Worker

Background script for caching and offline

sw.js

HTTPS

Secure connection required

https://example.com

Responsive Design

Works on all screen sizes

Mobile-first approach

App-like Experience

Native app feel and behavior

Smooth navigation, fast loading

Installation Criteria

Valid Web App Manifest

Properly formatted manifest.json

name, short_name, start_url required

HTTPS Connection

Secure context required

localhost allowed for development

Service Worker

Registered service worker

navigator.serviceWorker.register()

User Engagement

User interaction with site

Visit site multiple times

Install Prompt

Browser shows install button

beforeinstallprompt event

Essential Properties

name

Full app name

"name": "My Progressive Web App"

short_name

Short name for home screen

"short_name": "MyApp"

start_url

URL to load when app starts

"start_url": "/"

display

Display mode

"display": "standalone"

background_color

Background color during load

"background_color": "#ffffff"

theme_color

Theme color for browser UI

"theme_color": "#000000"

icons

App icons in various sizes

Array of icon objects with src, sizes, type

Optional Properties

description

App description

"description": "A great PWA app"

scope

Navigation scope

"scope": "/app/"

orientation

Preferred orientation

"orientation": "portrait"

categories

App categories

"categories": ["productivity", "utilities"]

lang

Primary language

"lang": "en-US"

dir

Text direction

"dir": "ltr"

prefer_related_applications

Prefer native apps

"prefer_related_applications": false

Service Worker Lifecycle

Install Event

Cache resources on install

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

Activate Event

Clean up old caches

self.addEventListener("activate", event => {})

Fetch Event

Intercept network requests

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

Message Event

Handle messages from main thread

self.addEventListener("message", event => {})

Push Event

Handle push notifications

self.addEventListener("push", event => {})

Caching Strategies

Cache First

Serve from cache, fallback to network

Cache-first for static assets

Network First

Try network, fallback to cache

Network-first for API calls

Stale While Revalidate

Serve cache, update in background

Good for frequently changing content

Network Only

Always fetch from network

For critical data

Cache Only

Serve only from cache

For static resources

Common Patterns

Basic Web App Manifest

Complete manifest.json example

{
  "name": "My Progressive Web App",
  "short_name": "MyApp",
  "description": "A great progressive web application",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#000000",
  "orientation": "portrait",
  "scope": "/",
  "lang": "en-US",
  "icons": [
    {
      "src": "/icons/icon-72x72.png",
      "sizes": "72x72",
      "type": "image/png",
      "purpose": "maskable any"
    },
    {
      "src": "/icons/icon-96x96.png",
      "sizes": "96x96",
      "type": "image/png",
      "purpose": "maskable any"
    },
    {
      "src": "/icons/icon-128x128.png",
      "sizes": "128x128",
      "type": "image/png",
      "purpose": "maskable any"
    },
    {
      "src": "/icons/icon-144x144.png",
      "sizes": "144x144",
      "type": "image/png",
      "purpose": "maskable any"
    },
    {
      "src": "/icons/icon-152x152.png",
      "sizes": "152x152",
      "type": "image/png",
      "purpose": "maskable any"
    },
    {
      "src": "/icons/icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "maskable any"
    },
    {
      "src": "/icons/icon-384x384.png",
      "sizes": "384x384",
      "type": "image/png",
      "purpose": "maskable any"
    },
    {
      "src": "/icons/icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "maskable any"
    }
  ],
  "categories": ["productivity", "utilities"],
  "prefer_related_applications": false
}

Service Worker Registration

Register service worker in main app

// app.js
if ('serviceWorker' in navigator) {
  window.addEventListener('load', () => {
    navigator.serviceWorker.register('/sw.js')
      .then((registration) => {
        console.log('SW registered: ', registration);
        
        // Check for updates
        registration.addEventListener('updatefound', () => {
          const newWorker = registration.installing;
          newWorker.addEventListener('statechange', () => {
            if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
              // New content is available
              showUpdateNotification();
            }
          });
        });
      })
      .catch((registrationError) => {
        console.log('SW registration failed: ', registrationError);
      });
  });
}

// Handle install prompt
let deferredPrompt;
window.addEventListener('beforeinstallprompt', (e) => {
  e.preventDefault();
  deferredPrompt = e;
  showInstallButton();
});

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

function installApp() {
  if (deferredPrompt) {
    deferredPrompt.prompt();
    deferredPrompt.userChoice.then((choiceResult) => {
      if (choiceResult.outcome === 'accepted') {
        console.log('User accepted the install prompt');
      } else {
        console.log('User dismissed the install prompt');
      }
      deferredPrompt = null;
    });
  }
}

function showUpdateNotification() {
  const updateNotification = document.getElementById('update-notification');
  if (updateNotification) {
    updateNotification.style.display = 'block';
    updateNotification.addEventListener('click', () => {
      window.location.reload();
    });
  }
}

Basic Service Worker

Service worker with caching strategies

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

const urlsToCache = [
  '/',
  '/index.html',
  '/styles/main.css',
  '/scripts/main.js',
  '/images/logo.png',
  '/offline.html'
];

// Install event - cache static resources
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(STATIC_CACHE)
      .then((cache) => {
        console.log('Opened cache');
        return cache.addAll(urlsToCache);
      })
  );
});

// Activate event - clean up old caches
self.addEventListener('activate', (event) => {
  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);
          }
        })
      );
    })
  );
});

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

  // Handle API requests with Network First strategy
  if (url.pathname.startsWith('/api/')) {
    event.respondWith(networkFirst(request));
  }
  // Handle static assets with Cache First strategy
  else if (isStaticAsset(request)) {
    event.respondWith(cacheFirst(request));
  }
  // Handle HTML pages with Stale While Revalidate
  else if (request.destination === 'document') {
    event.respondWith(staleWhileRevalidate(request));
  }
  // Default to network first
  else {
    event.respondWith(networkFirst(request));
  }
});

// Cache First strategy
async function cacheFirst(request) {
  const cachedResponse = await caches.match(request);
  if (cachedResponse) {
    return cachedResponse;
  }
  
  try {
    const networkResponse = await fetch(request);
    if (networkResponse.ok) {
      const cache = await caches.open(DYNAMIC_CACHE);
      cache.put(request, networkResponse.clone());
    }
    return networkResponse;
  } catch (error) {
    return new Response('Network error', { status: 408 });
  }
}

// Network First strategy
async function networkFirst(request) {
  try {
    const networkResponse = await fetch(request);
    if (networkResponse.ok) {
      const cache = await caches.open(DYNAMIC_CACHE);
      cache.put(request, networkResponse.clone());
    }
    return networkResponse;
  } catch (error) {
    const cachedResponse = await caches.match(request);
    if (cachedResponse) {
      return cachedResponse;
    }
    return new Response('Network error', { status: 408 });
  }
}

// Stale While Revalidate strategy
async function staleWhileRevalidate(request) {
  const cache = await caches.open(DYNAMIC_CACHE);
  const cachedResponse = await cache.match(request);
  
  const fetchPromise = fetch(request).then((networkResponse) => {
    if (networkResponse.ok) {
      cache.put(request, networkResponse.clone());
    }
    return networkResponse;
  }).catch(() => {
    return cachedResponse;
  });
  
  return cachedResponse || fetchPromise;
}

// Check if request is for a static asset
function isStaticAsset(request) {
  return request.destination === 'style' ||
         request.destination === 'script' ||
         request.destination === 'image' ||
         request.destination === 'font';
}

// Handle push notifications
self.addEventListener('push', (event) => {
  const options = {
    body: event.data ? event.data.text() : 'New notification',
    icon: '/icons/icon-192x192.png',
    badge: '/icons/badge-72x72.png',
    vibrate: [100, 50, 100],
    data: {
      dateOfArrival: Date.now(),
      primaryKey: 1
    },
    actions: [
      {
        action: 'explore',
        title: 'View',
        icon: '/icons/checkmark.png'
      },
      {
        action: 'close',
        title: 'Close',
        icon: '/icons/xmark.png'
      }
    ]
  };

  event.waitUntil(
    self.registration.showNotification('PWA Notification', options)
  );
});

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

  if (event.action === 'explore') {
    event.waitUntil(
      clients.openWindow('/')
    );
  }
});

PWA Installation Component

React component for PWA install prompt

// InstallPWA.jsx
import { useState, useEffect } from 'react';

const InstallPWA = () => {
  const [supportsPWA, setSupportsPWA] = useState(false);
  const [promptInstall, setPromptInstall] = useState(null);
  const [isInstalled, setIsInstalled] = useState(false);

  useEffect(() => {
    const handler = (e) => {
      e.preventDefault();
      setPromptInstall(e);
      setSupportsPWA(true);
    };

    window.addEventListener('beforeinstallprompt', handler);

    // Check if app is already installed
    if (window.matchMedia('(display-mode: standalone)').matches) {
      setIsInstalled(true);
    }

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

  const handleInstallClick = async () => {
    if (!promptInstall) {
      return;
    }

    promptInstall.prompt();
    const { outcome } = await promptInstall.userChoice;
    
    if (outcome === 'accepted') {
      console.log('User accepted the install prompt');
      setIsInstalled(true);
    } else {
      console.log('User dismissed the install prompt');
    }
    
    setPromptInstall(null);
  };

  if (!supportsPWA || isInstalled) {
    return null;
  }

  return (
    <div className="pwa-install-banner">
      <div className="pwa-install-content">
        <div className="pwa-install-text">
          <h3>Install App</h3>
          <p>Add this app to your home screen for quick access</p>
        </div>
        <div className="pwa-install-actions">
          <button
            onClick={handleInstallClick}
            className="pwa-install-button"
          >
            Install
          </button>
          <button
            onClick={() => setSupportsPWA(false)}
            className="pwa-dismiss-button"
          >
            Not now
          </button>
        </div>
      </div>
    </div>
  );
};

export default InstallPWA;

// CSS for the install banner
const styles = `
.pwa-install-banner {
  position: fixed;
  bottom: 0;
  left: 0;
  right: 0;
  background: #fff;
  border-top: 1px solid #e5e7eb;
  padding: 1rem;
  z-index: 1000;
  box-shadow: 0 -4px 6px -1px rgba(0, 0, 0, 0.1);
}

.pwa-install-content {
  display: flex;
  align-items: center;
  justify-content: space-between;
  max-width: 1200px;
  margin: 0 auto;
}

.pwa-install-text h3 {
  margin: 0 0 0.25rem 0;
  font-size: 1rem;
  font-weight: 600;
}

.pwa-install-text p {
  margin: 0;
  font-size: 0.875rem;
  color: #6b7280;
}

.pwa-install-actions {
  display: flex;
  gap: 0.5rem;
}

.pwa-install-button {
  background: #3b82f6;
  color: white;
  border: none;
  padding: 0.5rem 1rem;
  border-radius: 0.375rem;
  font-size: 0.875rem;
  font-weight: 500;
  cursor: pointer;
}

.pwa-dismiss-button {
  background: transparent;
  color: #6b7280;
  border: 1px solid #d1d5db;
  padding: 0.5rem 1rem;
  border-radius: 0.375rem;
  font-size: 0.875rem;
  cursor: pointer;
}
`;

Offline Page Component

React component for offline functionality

// OfflinePage.jsx
import { useState, useEffect } from 'react';

const OfflinePage = () => {
  const [isOnline, setIsOnline] = useState(navigator.onLine);
  const [offlineData, setOfflineData] = useState([]);

  useEffect(() => {
    const handleOnline = () => setIsOnline(true);
    const handleOffline = () => setIsOnline(false);

    window.addEventListener('online', handleOnline);
    window.addEventListener('offline', handleOffline);

    // Load offline data from IndexedDB
    loadOfflineData();

    return () => {
      window.removeEventListener('online', handleOnline);
      window.removeEventListener('offline', handleOffline);
    };
  }, []);

  const loadOfflineData = async () => {
    try {
      const db = await openDB('offlineDB', 1, {
        upgrade(db) {
          if (!db.objectStoreNames.contains('offlineData')) {
            db.createObjectStore('offlineData', { keyPath: 'id' });
          }
        },
      });

      const data = await db.getAll('offlineData');
      setOfflineData(data);
    } catch (error) {
      console.error('Error loading offline data:', error);
    }
  };

  const saveOfflineData = async (data) => {
    try {
      const db = await openDB('offlineDB', 1);
      await db.add('offlineData', {
        id: Date.now(),
        data,
        timestamp: new Date().toISOString()
      });
    } catch (error) {
      console.error('Error saving offline data:', error);
    }
  };

  if (isOnline) {
    return null;
  }

  return (
    <div className="offline-page">
      <div className="offline-content">
        <div className="offline-icon">
          <svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
            <line x1="1" y1="1" x2="23" y2="23"></line>
            <path d="M16.72 11.06A10.94 10.94 0 0 1 19 12.55"></path>
            <path d="M5 12.55a10.94 10.94 0 0 1 5.17-2.39"></path>
            <path d="M10.71 5.05A16 16 0 0 1 22.58 9"></path>
            <path d="M1.42 9a15.91 15.91 0 0 1 4.7-2.88"></path>
            <path d="M8.53 16.11a6 6 0 0 1 6.95 0"></path>
            <line x1="12" y1="20" x2="12.01" y2="20"></line>
          </svg>
        </div>
        
        <h1>You're Offline</h1>
        <p>Don't worry, you can still access some features of this app.</p>
        
        {offlineData.length > 0 && (
          <div className="offline-data">
            <h3>Available Offline</h3>
            <ul>
              {offlineData.map((item) => (
                <li key={item.id}>
                  {item.data.title} - {new Date(item.timestamp).toLocaleDateString()}
                </li>
              ))}
            </ul>
          </div>
        )}
        
        <button
          onClick={() => window.location.reload()}
          className="retry-button"
        >
          Try Again
        </button>
      </div>
    </div>
  );
};

// IndexedDB helper
function openDB(name, version, upgradeCallback) {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open(name, version);
    
    request.onerror = () => reject(request.error);
    request.onsuccess = () => resolve(request.result);
    request.onupgradeneeded = () => upgradeCallback(request.result);
  });
}

export default OfflinePage;

// CSS for offline page
const offlineStyles = `
.offline-page {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: #f9fafb;
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 9999;
}

.offline-content {
  text-align: center;
  padding: 2rem;
  max-width: 400px;
}

.offline-icon {
  color: #6b7280;
  margin-bottom: 1rem;
}

.offline-content h1 {
  margin: 0 0 1rem 0;
  font-size: 1.5rem;
  font-weight: 600;
  color: #111827;
}

.offline-content p {
  margin: 0 0 2rem 0;
  color: #6b7280;
  line-height: 1.5;
}

.offline-data {
  margin: 2rem 0;
  text-align: left;
}

.offline-data h3 {
  margin: 0 0 1rem 0;
  font-size: 1rem;
  font-weight: 600;
}

.offline-data ul {
  margin: 0;
  padding: 0;
  list-style: none;
}

.offline-data li {
  padding: 0.5rem 0;
  border-bottom: 1px solid #e5e7eb;
  font-size: 0.875rem;
}

.retry-button {
  background: #3b82f6;
  color: white;
  border: none;
  padding: 0.75rem 1.5rem;
  border-radius: 0.375rem;
  font-size: 0.875rem;
  font-weight: 500;
  cursor: pointer;
}

.retry-button:hover {
  background: #2563eb;
}
`;

PWA Testing Checklist

Comprehensive PWA testing checklist

// PWA Testing Checklist

## 1. Manifest Testing
- [ ] manifest.json is valid JSON
- [ ] All required fields are present (name, short_name, start_url, display)
- [ ] Icons are properly sized and formatted
- [ ] Theme colors are defined
- [ ] Manifest is linked in HTML head

## 2. Service Worker Testing
- [ ] Service worker registers successfully
- [ ] Caching strategies work correctly
- [ ] Offline functionality works
- [ ] Cache updates properly
- [ ] Background sync works (if implemented)

## 3. Installation Testing
- [ ] Install prompt appears on supported browsers
- [ ] App installs successfully
- [ ] App launches from home screen
- [ ] App icon displays correctly
- [ ] Splash screen shows properly

## 4. Performance Testing
- [ ] App loads quickly (under 3 seconds)
- [ ] Core Web Vitals are good
- [ ] App works on slow connections
- [ ] Offline-first experience is smooth
- [ ] No console errors

## 5. Cross-Browser Testing
- [ ] Chrome/Chromium browsers
- [ ] Firefox (limited PWA support)
- [ ] Safari (iOS and macOS)
- [ ] Edge
- [ ] Mobile browsers

## 6. Device Testing
- [ ] Android devices
- [ ] iOS devices
- [ ] Desktop computers
- [ ] Tablets
- [ ] Different screen sizes

## 7. Network Testing
- [ ] Fast 4G connection
- [ ] Slow 3G connection
- [ ] Offline mode
- [ ] Intermittent connectivity
- [ ] Network switching

## 8. Lighthouse Audit
- [ ] PWA score is 90+ (or target score)
- [ ] All PWA criteria are met
- [ ] Performance score is good
- [ ] Accessibility score is good
- [ ] Best practices are followed

## 9. Security Testing
- [ ] HTTPS is enforced
- [ ] No mixed content warnings
- [ ] Content Security Policy is set
- [ ] Service worker scope is secure
- [ ] No sensitive data in cache

## 10. User Experience Testing
- [ ] App feels native
- [ ] Navigation is smooth
- [ ] Loading states are clear
- [ ] Error handling is graceful
- [ ] Offline messaging is helpful

// Automated Testing Script
const lighthouse = require('lighthouse');
const chromeLauncher = require('chrome-launcher');

async function runPWAAudit(url) {
  const chrome = await chromeLauncher.launch({chromeFlags: ['--headless']});
  const options = {
    logLevel: 'info',
    output: 'json',
    onlyCategories: ['pwa'],
    port: chrome.port,
  };

  const runnerResult = await lighthouse(url, options);
  const reportJson = runnerResult.lhr;

  await chrome.kill();

  return {
    pwaScore: reportJson.categories.pwa.score * 100,
    installable: reportJson.audits['installable-manifest'].score === 1,
    serviceWorker: reportJson.audits['service-worker'].score === 1,
    https: reportJson.audits['is-on-https'].score === 1,
    offline: reportJson.audits['works-offline'].score === 1,
  };
}

// Usage
runPWAAudit('https://your-pwa.com')
  .then(results => {
    console.log('PWA Audit Results:', results);
  })
  .catch(error => {
    console.error('Audit failed:', error);
  });

Tips & Best Practices

Always use HTTPS in production (required for service workers)

Provide multiple icon sizes for different devices and contexts

Implement offline-first caching strategies

Test your PWA with Lighthouse and other audit tools

Handle app updates gracefully with user notifications