Progressive Web App fundamentals, service workers, and manifest files
JSON file defining app metadata
manifest.jsonBackground script for caching and offline
sw.jsSecure connection required
https://example.comWorks on all screen sizes
Mobile-first approachNative app feel and behavior
Smooth navigation, fast loadingProperly formatted manifest.json
name, short_name, start_url requiredSecure context required
localhost allowed for developmentRegistered service worker
navigator.serviceWorker.register()User interaction with site
Visit site multiple timesBrowser shows install button
beforeinstallprompt eventFull app name
"name": "My Progressive Web App"Short name for home screen
"short_name": "MyApp"URL to load when app starts
"start_url": "/"Display mode
"display": "standalone"Background color during load
"background_color": "#ffffff"Theme color for browser UI
"theme_color": "#000000"App icons in various sizes
Array of icon objects with src, sizes, typeApp description
"description": "A great PWA app"Navigation scope
"scope": "/app/"Preferred orientation
"orientation": "portrait"App categories
"categories": ["productivity", "utilities"]Primary language
"lang": "en-US"Text direction
"dir": "ltr"Prefer native apps
"prefer_related_applications": falseCache resources on install
self.addEventListener("install", event => {})Clean up old caches
self.addEventListener("activate", event => {})Intercept network requests
self.addEventListener("fetch", event => {})Handle messages from main thread
self.addEventListener("message", event => {})Handle push notifications
self.addEventListener("push", event => {})Serve from cache, fallback to network
Cache-first for static assetsTry network, fallback to cache
Network-first for API callsServe cache, update in background
Good for frequently changing contentAlways fetch from network
For critical dataServe only from cache
For static resourcesComplete 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
}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();
});
}
}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('/')
);
}
});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;
}
`;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;
}
`;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);
});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