Progressive Web Apps (PWA) represent the future of web applications—combining the openness of the web with the user experience of native apps. This article takes you through mastering the core technologies of PWA.
What is a PWA?
Core Characteristics of PWA
The Three Pillars of PWA:
┌─────────────────────────────────────────────────────┐
│ │
│ Reliable │
│ ├─ Works offline │
│ ├─ Functions on poor networks │
│ └─ Resource caching ensures fast loading │
│ │
│ Fast │
│ ├─ Loads in seconds │
│ ├─ Smooth interaction animations │
│ └─ Instant response to user actions │
│ │
│ Engaging │
│ ├─ Installable to home screen │
│ ├─ Runs fullscreen │
│ └─ Push notifications │
│ │
└─────────────────────────────────────────────────────┘
PWA vs Native Apps vs Traditional Web
| Feature | Traditional Web | PWA | Native App |
|---|---|---|---|
| Installation | Not required | Optional | Required |
| Offline | ❌ | ✅ | ✅ |
| Push Notifications | ❌ | ✅ | ✅ |
| Device APIs | Limited | Partial | Full |
| Updates | Automatic | Automatic | Download required |
| Distribution | URL | URL | App Store |
| Development Cost | Low | Low | High |
Web App Manifest
The Manifest is the PWA’s identity card, defining the app’s appearance and behavior.
Basic Configuration
// manifest.json
{
"name": "My Progressive Web App",
"short_name": "MyPWA",
"description": "A sample PWA application",
"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",
"purpose": "any maskable"
}
]
}
Referencing in HTML
<!DOCTYPE html>
<html>
<head>
<!-- Manifest -->
<link rel="manifest" href="/manifest.json">
<!-- iOS Support -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="MyPWA">
<link rel="apple-touch-icon" href="/icons/icon-192.png">
<!-- Theme Color -->
<meta name="theme-color" content="#3b82f6">
</head>
</html>
Display Modes Explained
Four display modes:
┌─────────────────────────────────────────────────────┐
│ fullscreen │
│ └─ Complete fullscreen, hides all browser UI │
│ │
│ standalone │
│ └─ Like native app, has status bar but no browser UI│
│ │
│ minimal-ui │
│ └─ Has minimal browser controls (like back button) │
│ │
│ browser │
│ └─ Normal browser tab (default) │
└─────────────────────────────────────────────────────┘
Service Worker: The Heart of PWA
Service Worker is a script running in the browser background—it’s the key to offline functionality.
Lifecycle
Service Worker Lifecycle:
┌─────────────────────────────────────────────────────┐
│ │
│ Register │
│ ↓ │
│ Install ──→ Precache resources │
│ ↓ │
│ Waiting ──→ Wait for old SW to release control │
│ ↓ │
│ Activate ──→ Clean up old caches │
│ ↓ │
│ Controlling ──→ Intercept network requests │
│ ↓ │
│ Terminated ──→ Reclaimed by browser when idle │
│ │
└─────────────────────────────────────────────────────┘
Registering Service Worker
// 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') {
if (navigator.serviceWorker.controller) {
// New version available, prompt user to refresh
showUpdateNotification();
}
}
});
});
} catch (error) {
console.error('SW registration failed:', error);
}
});
}
Service Worker Basic Structure
// sw.js
const CACHE_NAME = 'my-pwa-v1';
const ASSETS_TO_CACHE = [
'/',
'/index.html',
'/styles.css',
'/app.js',
'/icons/icon-192.png',
'/offline.html'
];
// Install event: precache resources
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => cache.addAll(ASSETS_TO_CACHE))
.then(() => self.skipWaiting()) // Activate immediately
);
});
// Activate event: clean up old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME)
.map((name) => caches.delete(name))
);
}).then(() => self.clients.claim()) // Take control immediately
);
});
// Intercept requests
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request)
.then((response) => response || fetch(event.request))
);
});
Caching Strategies
Different types of resources need different caching strategies.
Common Strategies Comparison
Caching Strategy Matrix:
┌────────────────────┬────────────────────────────────┐
│ Strategy │ Best For │
├────────────────────┼────────────────────────────────┤
│ Cache First │ Static assets, fonts, images │
│ Network First │ API requests, real-time data │
│ Stale While │ Content that can be briefly │
│ Revalidate │ stale │
│ Cache Only │ Offline page, app shell │
│ Network Only │ Must be real-time (payments) │
└────────────────────┴────────────────────────────────┘
Cache First
// Best for static resources
self.addEventListener('fetch', (event) => {
if (event.request.destination === 'image') {
event.respondWith(
caches.match(event.request).then((cachedResponse) => {
if (cachedResponse) {
return cachedResponse;
}
return fetch(event.request).then((networkResponse) => {
const responseClone = networkResponse.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseClone);
});
return networkResponse;
});
})
);
}
});
Network First
// Best for API requests
async function networkFirst(request, cacheName) {
const cache = await caches.open(cacheName);
try {
const networkResponse = await fetch(request);
// Update cache on success
cache.put(request, networkResponse.clone());
return networkResponse;
} catch (error) {
// Fall back to cache on failure
const cachedResponse = await cache.match(request);
if (cachedResponse) {
return cachedResponse;
}
throw error;
}
}
Stale While Revalidate
// Best for content that doesn't need immediate updates
async function staleWhileRevalidate(request, cacheName) {
const cache = await caches.open(cacheName);
const cachedResponse = await cache.match(request);
// Update cache in background
const fetchPromise = fetch(request).then((networkResponse) => {
cache.put(request, networkResponse.clone());
return networkResponse;
});
// Return cache immediately (if available)
return cachedResponse || fetchPromise;
}
Complete Routing Strategy
// sw.js
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// API requests: Network First
if (url.pathname.startsWith('/api/')) {
event.respondWith(networkFirst(request, 'api-cache'));
return;
}
// Static assets: Cache First
if (request.destination === 'image' ||
request.destination === 'font' ||
request.destination === 'style') {
event.respondWith(cacheFirst(request, 'static-cache'));
return;
}
// HTML pages: SWR
if (request.mode === 'navigate') {
event.respondWith(
staleWhileRevalidate(request, 'pages-cache')
.catch(() => caches.match('/offline.html'))
);
return;
}
// Default: Network First
event.respondWith(networkFirst(request, 'default-cache'));
});
Offline Experience
Offline Page
<!-- offline.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Offline - MyPWA</title>
<style>
body {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
font-family: system-ui;
background: #f3f4f6;
}
.icon { font-size: 4rem; margin-bottom: 1rem; }
h1 { color: #374151; margin-bottom: 0.5rem; }
p { color: #6b7280; }
button {
margin-top: 1rem;
padding: 0.75rem 1.5rem;
background: #3b82f6;
color: white;
border: none;
border-radius: 0.5rem;
cursor: pointer;
}
</style>
</head>
<body>
<div class="icon">📡</div>
<h1>You're Currently Offline</h1>
<p>Please check your network connection and try again</p>
<button onclick="location.reload()">Retry</button>
</body>
</html>
Detecting Online Status
// Listen for online/offline status changes
window.addEventListener('online', () => {
showToast('Network restored');
// Sync offline data
syncOfflineData();
});
window.addEventListener('offline', () => {
showToast('You are offline, some features may be limited');
});
// Check current status
function isOnline() {
return navigator.onLine;
}
Offline Data Sync
// Using Background Sync API
async function saveDataOffline(data) {
// Store in IndexedDB
await db.pendingRequests.add({
url: '/api/data',
method: 'POST',
body: data,
timestamp: Date.now()
});
// Register background sync
if ('serviceWorker' in navigator && 'sync' in window.ServiceWorkerRegistration.prototype) {
const registration = await navigator.serviceWorker.ready;
await registration.sync.register('sync-data');
}
}
// Handle sync in sw.js
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-data') {
event.waitUntil(syncPendingRequests());
}
});
async function syncPendingRequests() {
const db = await openDB();
const requests = await db.pendingRequests.getAll();
for (const request of requests) {
try {
await fetch(request.url, {
method: request.method,
body: JSON.stringify(request.body)
});
await db.pendingRequests.delete(request.id);
} catch (error) {
console.error('Sync failed:', error);
}
}
}
Push Notifications
Requesting Permission
async function requestNotificationPermission() {
if (!('Notification' in window)) {
console.log('Browser does not support notifications');
return false;
}
if (Notification.permission === 'granted') {
return true;
}
if (Notification.permission !== 'denied') {
const permission = await Notification.requestPermission();
return permission === 'granted';
}
return false;
}
Subscribing to Push
async function subscribeToPush() {
const registration = await navigator.serviceWorker.ready;
// Get push subscription
let subscription = await registration.pushManager.getSubscription();
if (!subscription) {
// Create new subscription
const vapidPublicKey = 'YOUR_VAPID_PUBLIC_KEY';
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey)
});
}
// Send subscription info to server
await fetch('/api/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(subscription)
});
return subscription;
}
// Helper function
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding)
.replace(/-/g, '+')
.replace(/_/g, '/');
const rawData = window.atob(base64);
return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0)));
}
Handling Push Messages
// sw.js
self.addEventListener('push', (event) => {
const data = event.data?.json() ?? {
title: 'New Message',
body: 'You have a new notification',
icon: '/icons/icon-192.png'
};
event.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
icon: data.icon,
badge: '/icons/badge.png',
vibrate: [200, 100, 200],
data: data.url,
actions: [
{ action: 'open', title: 'View' },
{ action: 'close', title: 'Close' }
]
})
);
});
// Handle notification click
self.addEventListener('notificationclick', (event) => {
event.notification.close();
if (event.action === 'open' || !event.action) {
event.waitUntil(
clients.openWindow(event.notification.data || '/')
);
}
});
App Installation
Detecting Installation State
let deferredPrompt;
window.addEventListener('beforeinstallprompt', (event) => {
// Prevent default install prompt
event.preventDefault();
// Save event for later use
deferredPrompt = event;
// Show custom install button
showInstallButton();
});
window.addEventListener('appinstalled', () => {
console.log('PWA installed');
hideInstallButton();
deferredPrompt = null;
});
Triggering Installation
async function installApp() {
if (!deferredPrompt) {
return;
}
// Show install prompt
deferredPrompt.prompt();
// Wait for user response
const { outcome } = await deferredPrompt.userChoice;
if (outcome === 'accepted') {
console.log('User accepted installation');
} else {
console.log('User dismissed installation');
}
deferredPrompt = null;
}
Custom Installation UI
<div id="install-prompt" class="install-prompt" hidden>
<div class="install-content">
<img src="/icons/icon-192.png" alt="App Icon" width="64">
<div>
<h3>Install MyPWA</h3>
<p>Install to home screen for a better experience</p>
</div>
</div>
<div class="install-actions">
<button onclick="dismissInstall()">Later</button>
<button onclick="installApp()" class="primary">Install</button>
</div>
</div>
Using Workbox
Workbox is Google’s Service Worker toolkit that greatly simplifies PWA development.
Installation and Configuration
npm install workbox-webpack-plugin
# or
npm install workbox-cli
Using Workbox
// sw.js
import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import {
CacheFirst,
NetworkFirst,
StaleWhileRevalidate
} from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
import { CacheableResponsePlugin } from 'workbox-cacheable-response';
// Precache (injected by build tool)
precacheAndRoute(self.__WB_MANIFEST);
// Image caching strategy
registerRoute(
({ request }) => request.destination === 'image',
new CacheFirst({
cacheName: 'images',
plugins: [
new ExpirationPlugin({
maxEntries: 60,
maxAgeSeconds: 30 * 24 * 60 * 60 // 30 days
}),
new CacheableResponsePlugin({
statuses: [0, 200]
})
]
})
);
// API caching strategy
registerRoute(
({ url }) => url.pathname.startsWith('/api/'),
new NetworkFirst({
cacheName: 'api-responses',
networkTimeoutSeconds: 3,
plugins: [
new ExpirationPlugin({
maxEntries: 50,
maxAgeSeconds: 5 * 60 // 5 minutes
})
]
})
);
// Page caching strategy
registerRoute(
({ request }) => request.mode === 'navigate',
new StaleWhileRevalidate({
cacheName: 'pages'
})
);
Workbox with Vite
// vite.config.js
import { VitePWA } from 'vite-plugin-pwa';
export default {
plugins: [
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['favicon.ico', 'robots.txt', 'apple-touch-icon.png'],
manifest: {
name: 'My PWA',
short_name: 'MyPWA',
theme_color: '#3b82f6',
icons: [
{
src: 'pwa-192x192.png',
sizes: '192x192',
type: 'image/png'
},
{
src: 'pwa-512x512.png',
sizes: '512x512',
type: 'image/png'
}
]
},
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
runtimeCaching: [
{
urlPattern: /^https:\/\/api\.example\.com\/.*/i,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
expiration: {
maxEntries: 10,
maxAgeSeconds: 60 * 60 * 24 // 24 hours
}
}
}
]
}
})
]
};
Performance Optimization
App Shell Architecture
App Shell Pattern:
┌─────────────────────────────────────────────────────┐
│ Header (cached) │
├─────────────────────────────────────────────────────┤
│ Navigation (cached) │
├─────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────┐ │
│ │ │ │
│ │ Dynamic Content (network request) │ │
│ │ │ │
│ └─────────────────────────────────────────────┘ │
│ │
├─────────────────────────────────────────────────────┤
│ Footer (cached) │
└─────────────────────────────────────────────────────┘
First load: Only need to load Shell (very small)
Subsequent visits: Shell loads instantly from cache,
content loads in parallel
Preloading Critical Resources
<head>
<!-- Preload critical resources -->
<link rel="preload" href="/fonts/main.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/styles/critical.css" as="style">
<link rel="preload" href="/scripts/app.js" as="script">
<!-- Preconnect to API server -->
<link rel="preconnect" href="https://api.example.com">
<link rel="dns-prefetch" href="https://api.example.com">
</head>
Debugging Tips
Chrome DevTools
Application Panel Features:
┌─────────────────────────────────────────────────────┐
│ Service Workers │
│ ├─ View registered SWs │
│ ├─ Update, skip waiting, unregister │
│ └─ Simulate offline mode │
│ │
│ Cache Storage │
│ ├─ View all caches │
│ ├─ Preview cached content │
│ └─ Delete caches │
│ │
│ Manifest │
│ ├─ Validate manifest configuration │
│ └─ Test install functionality │
└─────────────────────────────────────────────────────┘
Lighthouse Audit
# Using Lighthouse CLI
npm install -g lighthouse
lighthouse https://your-pwa.com --only-categories=pwa
# Report includes:
# - Installability checks
# - PWA optimization checklist
# - Service Worker verification
# - Offline functionality tests
Summary
PWA gives web apps a native experience:
| Technology | Purpose |
|---|---|
| Manifest | Define app appearance and installation behavior |
| Service Worker | Intercept requests, cache resources, offline support |
| Cache API | Fine-grained caching control |
| Push API | Push notifications |
| Background Sync | Offline data synchronization |
| Workbox | Simplify SW development |
Key Takeaways:
- PWA is progressive—you can incrementally enhance existing apps
- Service Worker is core—mastering its lifecycle is crucial
- Caching strategies must be tailored—different resources need different strategies
- Offline experience needs careful design—not just showing an error page
- Workbox greatly simplifies development—recommended for production
PWA isn’t about replacing native apps—it’s about reaching more users with web apps and providing better experiences.
The future of the web is progressive—PWA enables every website to become an app.