Progressive Web Apps combine the best of web and native apps. They're fast, reliable, and installable—all without app store approval.
PWA Checklist#
Core Requirements:
✓ HTTPS
✓ Service worker
✓ Web app manifest
✓ Responsive design
Enhanced Features:
✓ Offline functionality
✓ Push notifications
✓ Background sync
✓ Install prompt
Web App Manifest#
1// manifest.json
2{
3 "name": "My PWA Application",
4 "short_name": "My PWA",
5 "description": "A progressive web application",
6 "start_url": "/",
7 "display": "standalone",
8 "background_color": "#ffffff",
9 "theme_color": "#3b82f6",
10 "orientation": "portrait-primary",
11 "icons": [
12 {
13 "src": "/icons/icon-192.png",
14 "sizes": "192x192",
15 "type": "image/png",
16 "purpose": "any maskable"
17 },
18 {
19 "src": "/icons/icon-512.png",
20 "sizes": "512x512",
21 "type": "image/png"
22 }
23 ],
24 "screenshots": [
25 {
26 "src": "/screenshots/home.png",
27 "sizes": "1280x720",
28 "type": "image/png",
29 "form_factor": "wide"
30 }
31 ],
32 "shortcuts": [
33 {
34 "name": "New Order",
35 "url": "/orders/new",
36 "icons": [{ "src": "/icons/new-order.png", "sizes": "96x96" }]
37 }
38 ]
39}<!-- Link in HTML -->
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#3b82f6">
<link rel="apple-touch-icon" href="/icons/icon-192.png">Service Workers#
Basic Registration#
1// Register service worker
2if ('serviceWorker' in navigator) {
3 window.addEventListener('load', async () => {
4 try {
5 const registration = await navigator.serviceWorker.register('/sw.js');
6 console.log('SW registered:', registration.scope);
7 } catch (error) {
8 console.error('SW registration failed:', error);
9 }
10 });
11}Service Worker Implementation#
1// sw.js
2const CACHE_NAME = 'my-pwa-v1';
3const STATIC_ASSETS = [
4 '/',
5 '/index.html',
6 '/styles.css',
7 '/app.js',
8 '/icons/icon-192.png',
9];
10
11// Install: Cache static assets
12self.addEventListener('install', (event) => {
13 event.waitUntil(
14 caches.open(CACHE_NAME).then((cache) => {
15 return cache.addAll(STATIC_ASSETS);
16 })
17 );
18 self.skipWaiting();
19});
20
21// Activate: Clean old caches
22self.addEventListener('activate', (event) => {
23 event.waitUntil(
24 caches.keys().then((keys) => {
25 return Promise.all(
26 keys
27 .filter((key) => key !== CACHE_NAME)
28 .map((key) => caches.delete(key))
29 );
30 })
31 );
32 self.clients.claim();
33});
34
35// Fetch: Serve from cache, fallback to network
36self.addEventListener('fetch', (event) => {
37 event.respondWith(
38 caches.match(event.request).then((cached) => {
39 if (cached) {
40 return cached;
41 }
42
43 return fetch(event.request).then((response) => {
44 // Cache successful responses
45 if (response.ok) {
46 const clone = response.clone();
47 caches.open(CACHE_NAME).then((cache) => {
48 cache.put(event.request, clone);
49 });
50 }
51 return response;
52 });
53 })
54 );
55});Caching Strategies#
Cache First#
1// Best for: Static assets, fonts, images
2async function cacheFirst(request: Request): Promise<Response> {
3 const cached = await caches.match(request);
4 if (cached) return cached;
5
6 const response = await fetch(request);
7 const cache = await caches.open(CACHE_NAME);
8 cache.put(request, response.clone());
9 return response;
10}Network First#
1// Best for: API calls, frequently updated content
2async function networkFirst(request: Request): Promise<Response> {
3 try {
4 const response = await fetch(request);
5 const cache = await caches.open(CACHE_NAME);
6 cache.put(request, response.clone());
7 return response;
8 } catch {
9 const cached = await caches.match(request);
10 if (cached) return cached;
11 throw new Error('No cached response');
12 }
13}Stale While Revalidate#
1// Best for: Avatars, non-critical images
2async function staleWhileRevalidate(request: Request): Promise<Response> {
3 const cache = await caches.open(CACHE_NAME);
4 const cached = await cache.match(request);
5
6 const fetchPromise = fetch(request).then((response) => {
7 cache.put(request, response.clone());
8 return response;
9 });
10
11 return cached || fetchPromise;
12}Install Prompt#
1// Handle install prompt
2let deferredPrompt: BeforeInstallPromptEvent | null = null;
3
4window.addEventListener('beforeinstallprompt', (e) => {
5 e.preventDefault();
6 deferredPrompt = e;
7 showInstallButton();
8});
9
10async function installApp() {
11 if (!deferredPrompt) return;
12
13 deferredPrompt.prompt();
14 const { outcome } = await deferredPrompt.userChoice;
15
16 if (outcome === 'accepted') {
17 console.log('User installed the app');
18 }
19
20 deferredPrompt = null;
21 hideInstallButton();
22}
23
24window.addEventListener('appinstalled', () => {
25 console.log('App was installed');
26 hideInstallButton();
27});Push Notifications#
Request Permission#
1async function requestNotificationPermission(): Promise<boolean> {
2 if (!('Notification' in window)) return false;
3
4 const permission = await Notification.requestPermission();
5 return permission === 'granted';
6}Subscribe to Push#
1async function subscribeToPush(): Promise<PushSubscription | null> {
2 const registration = await navigator.serviceWorker.ready;
3
4 const subscription = await registration.pushManager.subscribe({
5 userVisibleOnly: true,
6 applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
7 });
8
9 // Send subscription to server
10 await fetch('/api/push/subscribe', {
11 method: 'POST',
12 body: JSON.stringify(subscription),
13 headers: { 'Content-Type': 'application/json' },
14 });
15
16 return subscription;
17}Handle Push in Service Worker#
1// sw.js
2self.addEventListener('push', (event) => {
3 const data = event.data?.json() || {};
4
5 event.waitUntil(
6 self.registration.showNotification(data.title, {
7 body: data.body,
8 icon: '/icons/icon-192.png',
9 badge: '/icons/badge.png',
10 data: data.url,
11 actions: [
12 { action: 'view', title: 'View' },
13 { action: 'dismiss', title: 'Dismiss' },
14 ],
15 })
16 );
17});
18
19self.addEventListener('notificationclick', (event) => {
20 event.notification.close();
21
22 if (event.action === 'view' || !event.action) {
23 event.waitUntil(
24 clients.openWindow(event.notification.data || '/')
25 );
26 }
27});Background Sync#
1// Register sync
2async function saveForLater(data: any): Promise<void> {
3 const db = await openDB();
4 await db.put('pending', data);
5
6 const registration = await navigator.serviceWorker.ready;
7 await registration.sync.register('sync-data');
8}
9
10// Handle in service worker
11self.addEventListener('sync', (event) => {
12 if (event.tag === 'sync-data') {
13 event.waitUntil(syncPendingData());
14 }
15});
16
17async function syncPendingData(): Promise<void> {
18 const db = await openDB();
19 const pending = await db.getAll('pending');
20
21 for (const item of pending) {
22 try {
23 await fetch('/api/data', {
24 method: 'POST',
25 body: JSON.stringify(item),
26 });
27 await db.delete('pending', item.id);
28 } catch {
29 // Will retry on next sync
30 }
31 }
32}Workbox#
1// Simplify service worker with Workbox
2import { precacheAndRoute } from 'workbox-precaching';
3import { registerRoute } from 'workbox-routing';
4import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies';
5
6// Precache static assets
7precacheAndRoute(self.__WB_MANIFEST);
8
9// Cache images
10registerRoute(
11 ({ request }) => request.destination === 'image',
12 new CacheFirst({ cacheName: 'images' })
13);
14
15// API calls
16registerRoute(
17 ({ url }) => url.pathname.startsWith('/api'),
18 new NetworkFirst({ cacheName: 'api' })
19);
20
21// Fonts
22registerRoute(
23 ({ url }) => url.origin === 'https://fonts.googleapis.com',
24 new StaleWhileRevalidate({ cacheName: 'fonts' })
25);Conclusion#
PWAs offer native-like experiences with web technology. Start with the manifest and basic service worker, then add offline support and push notifications as needed.
The key is progressive enhancement—the app works everywhere but shines when features are available.