What Makes a PWA?
Progressive Web Apps combine web technologies with native app capabilities: offline support, installability, push notifications, and fast performance.
Web App Manifest
// manifest.json
{
"name": "My Awesome App",
"short_name": "AwesomeApp",
"description": "A progressive web app",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#3b82f6",
"orientation": "portrait-primary",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"shortcuts": [
{
"name": "New Item",
"url": "/new",
"icons": [{ "src": "/icons/new.png", "sizes": "96x96" }]
}
],
"screenshots": [
{
"src": "/screenshots/desktop.png",
"sizes": "1280x720",
"type": "image/png",
"form_factor": "wide"
}
]
}Service Worker Registration
// main.js
if ('serviceWorker' in navigator) {
window.addEventListener('load', async () => {
try {
const registration = await navigator.serviceWorker.register('/sw.js', {
scope: '/'
});
console.log('SW registered:', registration.scope);
// Check for updates
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
// New version available
showUpdateNotification();
}
});
});
} catch (error) {
console.error('SW registration failed:', error);
}
});
}Caching Strategies
// sw.js
const CACHE_NAME = 'app-v1';
const STATIC_ASSETS = [
'/',
'/index.html',
'/styles.css',
'/app.js',
'/offline.html'
];
// 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((keys) => {
return Promise.all(
keys.filter(key => key !== CACHE_NAME).map(key => caches.delete(key))
);
})
);
self.clients.claim();
});
// Fetch: Stale-while-revalidate for API, cache-first for assets
self.addEventListener('fetch', (event) => {
const { request } = event;
if (request.url.includes('/api/')) {
// Network first, fallback to cache
event.respondWith(networkFirst(request));
} else {
// Cache first, fallback to network
event.respondWith(cacheFirst(request));
}
});
async function cacheFirst(request) {
const cached = await caches.match(request);
if (cached) return cached;
try {
const response = await fetch(request);
const cache = await caches.open(CACHE_NAME);
cache.put(request, response.clone());
return response;
} catch {
return caches.match('/offline.html');
}
}
async function networkFirst(request) {
try {
const response = await fetch(request);
const cache = await caches.open(CACHE_NAME);
cache.put(request, response.clone());
return response;
} catch {
return caches.match(request);
}
}Background Sync
// Queue failed requests for later sync
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-messages') {
event.waitUntil(syncMessages());
}
});
async function syncMessages() {
const db = await openDB('app-db', 1);
const messages = await db.getAll('pending-messages');
for (const message of messages) {
try {
await fetch('/api/messages', {
method: 'POST',
body: JSON.stringify(message)
});
await db.delete('pending-messages', message.id);
} catch {
// Will retry on next sync
break;
}
}
}
// Register sync from app
async function sendMessage(message) {
const db = await openDB('app-db', 1);
await db.add('pending-messages', message);
const registration = await navigator.serviceWorker.ready;
await registration.sync.register('sync-messages');
}Push Notifications
// Request permission and subscribe
async function subscribeToPush() {
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-192.png',
badge: '/icons/badge.png',
data: { url: data.url },
actions: [
{ action: 'open', title: 'Open' },
{ action: 'dismiss', title: 'Dismiss' }
]
})
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
if (event.action === 'open') {
event.waitUntil(
clients.openWindow(event.notification.data.url)
);
}
});Install Prompt
let deferredPrompt;
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
deferredPrompt = e;
showInstallButton();
});
async function handleInstallClick() {
if (!deferredPrompt) return;
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
if (outcome === 'accepted') {
console.log('App installed');
}
deferredPrompt = null;
}Conclusion
PWAs bridge the gap between web and native. Start with a manifest and service worker, then progressively enhance with push notifications, background sync, and offline support.