Building Progressive Web Apps (PWA)
What is a Progressive Web App?
Progressive Web Apps (PWAs) combine the best of web and mobile apps. They're web applications that use modern web capabilities to deliver an app-like experience to users. PWAs are reliable, fast, and engaging, working offline and loading instantly regardless of network conditions.
Key Features of PWAs
- Reliable: Load instantly even in uncertain network conditions
- Fast: Respond quickly to user interactions with smooth animations
- Engaging: Feel like a natural app on the device with immersive user experience
- Installable: Can be installed on the home screen without an app store
- Offline-first: Work offline or on low-quality networks
- Discoverable: Identifiable as applications and discoverable by search engines
1. Web App Manifest
The web app manifest is a JSON file that tells the browser about your PWA and how it should behave when installed.
manifest.json Example
{
"name": "My Amazing PWA",
"short_name": "PWA",
"description": "A progressive web app example",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#2196F3",
"orientation": "portrait-primary",
"icons": [
{
"src": "/icons/icon-72x72.png",
"sizes": "72x72",
"type": "image/png"
},
{
"src": "/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}
Link Manifest in HTML
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#2196F3">
2. Service Workers
Service workers are the core technology behind PWAs. They're JavaScript files that run in the background, enabling features like offline support, background sync, and push notifications.
Registering a Service Worker
// In your main JavaScript file
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/service-worker.js')
.then(registration => {
console.log('SW registered:', registration);
})
.catch(error => {
console.log('SW registration failed:', error);
});
});
}
Basic Service Worker (service-worker.js)
const CACHE_NAME = 'my-pwa-cache-v1';
const urlsToCache = [
'/',
'/styles/main.css',
'/script/main.js',
'/images/logo.png'
];
// Install event - cache resources
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => {
console.log('Opened cache');
return cache.addAll(urlsToCache);
})
);
});
// Fetch event - serve from cache
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request)
.then(response => {
// Cache hit - return response
if (response) {
return response;
}
return fetch(event.request);
}
)
);
});
// Activate event - clean old caches
self.addEventListener('activate', event => {
const cacheWhitelist = [CACHE_NAME];
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames.map(cacheName => {
if (cacheWhitelist.indexOf(cacheName) === -1) {
return caches.delete(cacheName);
}
})
);
})
);
});
3. Caching Strategies
Different resources require different caching strategies:
Cache First (Best for static assets)
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request)
.then(response => response || fetch(event.request))
);
});
Network First (Best for dynamic data)
self.addEventListener('fetch', event => {
event.respondWith(
fetch(event.request)
.catch(() => caches.match(event.request))
);
});
Stale While Revalidate
self.addEventListener('fetch', event => {
event.respondWith(
caches.open(CACHE_NAME).then(cache => {
return cache.match(event.request).then(response => {
const fetchPromise = fetch(event.request).then(networkResponse => {
cache.put(event.request, networkResponse.clone());
return networkResponse;
});
return response || fetchPromise;
});
})
);
});
4. Offline Functionality
Create an offline fallback page for better UX:
const OFFLINE_URL = '/offline.html';
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => cache.add(OFFLINE_URL))
);
});
self.addEventListener('fetch', event => {
if (event.request.mode === 'navigate') {
event.respondWith(
fetch(event.request)
.catch(() => caches.match(OFFLINE_URL))
);
}
});
5. Push Notifications
Engage users with push notifications even when they're not on your site:
Request Permission
async function subscribeToPush() {
const permission = await Notification.requestPermission();
if (permission === 'granted') {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicVapidKey)
});
// Send subscription to your server
await fetch('/api/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(subscription)
});
}
}
Show Notification in Service Worker
self.addEventListener('push', event => {
const data = event.data.json();
const options = {
body: data.body,
icon: '/images/icon.png',
badge: '/images/badge.png',
vibrate: [200, 100, 200],
data: { url: data.url }
};
event.waitUntil(
self.registration.showNotification(data.title, options)
);
});
self.addEventListener('notificationclick', event => {
event.notification.close();
event.waitUntil(
clients.openWindow(event.notification.data.url)
);
});
6. Background Sync
Defer actions until the user has stable connectivity:
// Register sync in your app
navigator.serviceWorker.ready.then(registration => {
return registration.sync.register('sync-messages');
});
// Handle sync in service worker
self.addEventListener('sync', event => {
if (event.tag === 'sync-messages') {
event.waitUntil(sendMessages());
}
});
async function sendMessages() {
const db = await openDB();
const messages = await db.getAll('outbox');
for (const message of messages) {
try {
await fetch('/api/messages', {
method: 'POST',
body: JSON.stringify(message)
});
await db.delete('outbox', message.id);
} catch (error) {
console.error('Failed to send:', error);
}
}
}
7. Installation Prompt
Encourage users to install your PWA:
let deferredPrompt;
window.addEventListener('beforeinstallprompt', e => {
e.preventDefault();
deferredPrompt = e;
showInstallButton();
});
function showInstallButton() {
const installButton = document.getElementById('install-button');
installButton.style.display = 'block';
installButton.addEventListener('click', async () => {
if (!deferredPrompt) return;
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
console.log(`User response: ${outcome}`);
deferredPrompt = null;
installButton.style.display = 'none';
});
}
8. Testing Your PWA
Use these tools to test your PWA:
- Lighthouse: Chrome DevTools > Lighthouse > Progressive Web App audit
- PWA Builder: https://www.pwabuilder.com/ for validation and packaging
- Chrome DevTools: Application tab to inspect manifest, service workers, and storage
- WebPageTest: Test performance and loading times
- Always serve your PWA over HTTPS (required for service workers)
- Test offline functionality thoroughly
- Provide multiple icon sizes for different devices
- Implement proper error handling for network failures
- Use cache versioning to manage updates
- Monitor service worker lifecycle events
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 support, and installability, you can create engaging, reliable experiences that users love.
Start small, add PWA features incrementally, and use analytics to measure the impact on user engagement and retention. Your users will appreciate the improved experience, especially on mobile devices and slow networks.