Progressive Web App fundamentals, service workers, and manifest files
JSON file defining app metadata
manifest.json
Background script for caching and offline
sw.js
Secure connection required
https://example.com
Works on all screen sizes
Mobile-first approach
Native app feel and behavior
Smooth navigation, fast loading
Properly formatted manifest.json
name, short_name, start_url required
Secure context required
localhost allowed for development
Registered service worker
navigator.serviceWorker.register()
User interaction with site
Visit site multiple times
Browser shows install button
beforeinstallprompt event
Full 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, type
App 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": false
Cache 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 assets
Try network, fallback to cache
Network-first for API calls
Serve cache, update in background
Good for frequently changing content
Always fetch from network
For critical data
Serve only from cache
For static resources
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
}
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