Progressive Web App (PWA) Development
Build installable, offline-capable web applications that work across all platforms.
PWA Capabilities
| Feature |
Support |
| Offline access |
All modern browsers |
| Install prompt |
Chrome, Edge, Samsung |
| Push notifications |
All except iOS Safari |
| Background sync |
Chrome, Edge |
| File handling |
Chrome, Edge |
| Share target |
Chrome, Edge, Safari |
Core Requirements
1. Web App Manifest
// public/manifest.json
{
"name": "My Progressive Web App",
"short_name": "MyPWA",
"description": "A progressive web application",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#3b82f6",
"orientation": "portrait-primary",
"scope": "/",
"icons": [
{
"src": "/icons/icon-72x72.png",
"sizes": "72x72",
"type": "image/png",
"purpose": "maskable any"
},
{
"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"
},
{
"src": "/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"screenshots": [
{
"src": "/screenshots/desktop.png",
"sizes": "1280x720",
"type": "image/png",
"form_factor": "wide"
},
{
"src": "/screenshots/mobile.png",
"sizes": "750x1334",
"type": "image/png",
"form_factor": "narrow"
}
],
"shortcuts": [
{
"name": "New Document",
"short_name": "New",
"url": "/new",
"icons": [{ "src": "/icons/new.png", "sizes": "96x96" }]
}
],
"share_target": {
"action": "/share",
"method": "POST",
"enctype": "multipart/form-data",
"params": {
"title": "title",
"text": "text",
"url": "url",
"files": [
{
"name": "files",
"accept": ["image/*", "text/*"]
}
]
}
}
}
2. HTML Meta Tags
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- PWA Meta Tags -->
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#3b82f6">
<meta name="description" content="My Progressive Web App">
<!-- iOS Specific -->
<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-192x192.png">
<!-- Windows Specific -->
<meta name="msapplication-TileColor" content="#3b82f6">
<meta name="msapplication-TileImage" content="/icons/icon-144x144.png">
<title>My PWA</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
Service Worker
Basic Service Worker
// public/sw.js
const CACHE_NAME = 'my-pwa-v1';
const STATIC_ASSETS = [
'/',
'/index.html',
'/manifest.json',
'/icons/icon-192x192.png',
];
// Install - cache static assets
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(STATIC_ASSETS);
})
);
self.skipWaiting();
});
// Activate - clean old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME)
.map((name) => caches.delete(name))
);
})
);
self.clients.claim();
});
// Fetch - serve from cache, fallback to network
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then((cached) => {
// Cache-first strategy
if (cached) {
return cached;
}
// Network fallback
return fetch(event.request).then((response) => {
// Don't cache non-GET or failed requests
if (event.request.method !== 'GET' || !response.ok) {
return response;
}
// Cache successful responses
const responseClone = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseClone);
});
return response;
});
})
);
});
Workbox (Recommended)
// src/sw.ts
import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import {
CacheFirst,
NetworkFirst,
StaleWhileRevalidate,
} from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
// Precache static assets
precacheAndRoute(self.__WB_MANIFEST);
// Cache images
registerRoute(
({ request }) => request.destination === 'image',
new CacheFirst({
cacheName: 'images',
plugins: [
new ExpirationPlugin({
maxEntries: 100,
maxAgeSeconds: 30 * 24 * 60 * 60, // 30 days
}),
],
})
);
// Network-first for API calls
registerRoute(
({ url }) => url.pathname.startsWith('/api/'),
new NetworkFirst({
cacheName: 'api-cache',
plugins: [
new ExpirationPlugin({
maxEntries: 50,
maxAgeSeconds: 5 * 60, // 5 minutes
}),
],
})
);
// Stale-while-revalidate for pages
registerRoute(
({ request }) => request.mode === 'navigate',
new StaleWhileRevalidate({
cacheName: 'pages',
})
);
Service Worker Registration
// src/registerSW.ts
export async function registerServiceWorker() {
if ('serviceWorker' in navigator) {
try {
const registration = await navigator.serviceWorker.register('/sw.js', {
scope: '/',
});
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
if (newWorker) {
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed') {
if (navigator.serviceWorker.controller) {
// New update available
dispatchEvent(new CustomEvent('sw:update'));
}
}
});
}
});
console.log('Service Worker registered:', registration.scope);
} catch (error) {
console.error('Service Worker registration failed:', error);
}
}
}
Install Prompt
Custom Install Button
import { useState, useEffect } from 'react';
interface BeforeInstallPromptEvent extends Event {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
}
function InstallButton() {
const [deferredPrompt, setDeferredPrompt] =
useState<BeforeInstallPromptEvent | null>(null);
const [isInstalled, setIsInstalled] = useState(false);
useEffect(() => {
// Check if already installed
if (window.matchMedia('(display-mode: standalone)').matches) {
setIsInstalled(true);
return;
}
const handler = (e: Event) => {
e.preventDefault();
setDeferredPrompt(e as BeforeInstallPromptEvent);
};
window.addEventListener('beforeinstallprompt', handler);
return () => window.removeEventListener('beforeinstallprompt', handler);
}, []);
const handleInstall = async () => {
if (!deferredPrompt) return;
await deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
if (outcome === 'accepted') {
setIsInstalled(true);
}
setDeferredPrompt(null);
};
if (isInstalled || !deferredPrompt) return null;
return (
<button onClick={handleInstall} className="install-button">
Install App
</button>
);
}
Offline Support
Offline Detection
import { useState, useEffect } from 'react';
function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(navigator.onLine);
useEffect(() => {
const handleOnline = () => setIsOnline(true);
const handleOffline = () => setIsOnline(false);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
return isOnline;
}
// Usage
function App() {
const isOnline = useOnlineStatus();
return (
<div>
{!isOnline && (
<div className="offline-banner">
You're offline. Some features may be unavailable.
</div>
)}
</div>
);
}
Background Sync
// In Service Worker
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-data') {
event.waitUntil(syncData());
}
});
async function syncData() {
const db = await openDB('pending-requests', 1);
const requests = await db.getAll('requests');
for (const request of requests) {
try {
await fetch(request.url, request.options);
await db.delete('requests', request.id);
} catch {
// Will retry on next sync
}
}
}
// Register sync from app
async function queueRequest(url: string, options: RequestInit) {
await navigator.serviceWorker.ready;
if ('sync' in window.ServiceWorkerRegistration.prototype) {
// Store request and trigger sync
const db = await openDB('pending-requests', 1);
await db.add('requests', { url, options, id: Date.now() });
await navigator.serviceWorker.ready.then((reg) =>
reg.sync.register('sync-data')
);
} else {
// Fallback to immediate fetch
await fetch(url, options);
}
}
Push Notifications
Request Permission
async function requestNotificationPermission() {
const permission = await Notification.requestPermission();
if (permission === 'granted') {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
});
// Send subscription to server
await fetch('/api/push/subscribe', {
method: 'POST',
body: JSON.stringify(subscription),
});
}
}
Handle Push in Service Worker
self.addEventListener('push', (event) => {
const data = event.data?.json() ?? {};
event.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
icon: '/icons/icon-192x192.png',
badge: '/icons/badge.png',
data: data.url,
})
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
if (event.notification.data) {
event.waitUntil(
clients.openWindow(event.notification.data)
);
}
});
Vite PWA Plugin
// vite.config.ts
import { VitePWA } from 'vite-plugin-pwa';
export default defineConfig({
plugins: [
react(),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['favicon.ico', 'robots.txt', 'icons/*.png'],
manifest: {
name: 'My PWA',
short_name: 'MyPWA',
theme_color: '#3b82f6',
icons: [
// ... icon definitions
],
},
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
runtimeCaching: [
{
urlPattern: /^https:\/\/api\.example\.com\/.*/i,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
expiration: {
maxEntries: 100,
maxAgeSeconds: 60 * 60 * 24, // 24 hours
},
},
},
],
},
}),
],
});
Testing PWA
Lighthouse Audit
- Open Chrome DevTools
- Go to Lighthouse tab
- Select "Progressive Web App"
- Run audit
Required Scores
- Performance: 90+
- PWA: 100
- Accessibility: 90+
- Best Practices: 90+
Best Practices
DO:
- Use HTTPS (required)
- Provide offline fallback
- Cache static assets
- Show update notification
- Handle all screen sizes
DON'T:
- Cache everything forever
- Block app on SW update
- Ignore iOS limitations
- Skip manifest icons
- Forget offline states