Why PWAs Matter in 2022
The Statistics:
- 53% of mobile users abandon sites that take > 3 seconds to load
- Average user has 80 apps installed, uses only 9 regularly
- 80% of time spent in apps, not mobile browsers
- But: Users download 0 new apps per month on average
The Problem:
- Mobile web is too slow
- Native apps have high barrier to entry
- App stores have discovery problems
The Solution: Progressive Web Apps
What Are PWAs? Web apps that feel like native apps:
- Install to home screen
- Work offline
- Fast loading
- Push notifications
- Camera, GPS access
- No app store needed
Success Stories:
Twitter Lite (PWA):
- 65% increase in pages per session
- 75% increase in Tweets sent
- 20% decrease in bounce rate
- 70% faster load time
Pinterest PWA:
- 60% increase in core engagements
- 44% increase in ad revenue
- 50% increase in user-generated revenue
Starbucks PWA:
- 2x daily active users
- 99.84% smaller than native iOS app
This guide shows you how to build one.
PWA Core Concepts
Three Pillars:
1. Reliable
- Load instantly
- Work offline
- Handle poor network conditions
2. Fast
- Respond quickly to user interactions
- Smooth animations
- No janky scrolling
3. Engaging
- Installable to home screen
- Push notifications
- Full-screen experience
Technical Requirements:
HTTPS (required for service workers) Service Worker (offline functionality) Web App Manifest (install prompt) Responsive Design (all screen sizes) App Shell Architecture (fast loading)
Step 1: Web App Manifest
What It Is: JSON file describing your app.
manifest.json:
{
"name": "My Awesome PWA",
"short_name": "PWA",
"description": "A progressive web application",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#4285f4",
"orientation": "portrait-primary",
"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"
}
],
"screenshots": [
{
"src": "/screenshots/home.png",
"sizes": "540x720",
"type": "image/png"
},
{
"src": "/screenshots/detail.png",
"sizes": "540x720",
"type": "image/png"
}
],
"categories": ["productivity", "utilities"],
"shortcuts": [
{
"name": "New Task",
"short_name": "New",
"description": "Create a new task",
"url": "/tasks/new",
"icons": [{ "src": "/icons/new.png", "sizes": "96x96" }]
}
]
}
Link in HTML:
<link rel="manifest" href="/manifest.json">
Key Properties:
display modes:
standalone- Looks like native app (recommended)fullscreen- Entire screenminimal-ui- Basic browser UIbrowser- Regular browser tab
icons:
- Minimum: 192×192 and 512×512
- Maskable icons for Android adaptive icons
- Use solid color background (no transparency)
screenshots:
- For app store-like install prompt
- Minimum 320px, maximum 3840px
- 1-8 screenshots
Step 2: Service Worker Basics
What Is It: JavaScript that runs in background, separate from web page.
Powers:
- Offline functionality
- Background sync
- Push notifications
- Caching strategies
Lifecycle:
1. Register
2. Install (cache assets)
3. Activate (clean old caches)
4. Fetch (intercept network requests)
Basic Service Worker:
sw.js:
const CACHE_NAME = 'my-pwa-cache-v1';
const urlsToCache = [
'/',
'/styles/main.css',
'/script/main.js',
'/images/logo.png'
];
// Install event - cache assets
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => {
console.log('Opened cache');
return cache.addAll(urlsToCache);
})
);
});
// Activate event - clean old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
if (cacheName !== CACHE_NAME) {
console.log('Deleting old cache:', cacheName);
return caches.delete(cacheName);
}
})
);
})
);
});
// Fetch event - serve from cache, fallback to network
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request)
.then((response) => {
// Cache hit - return response
if (response) {
return response;
}
// Clone request
const fetchRequest = event.request.clone();
return fetch(fetchRequest).then((response) => {
// Check if valid response
if (!response || response.status !== 200 || response.type !== 'basic') {
return response;
}
// Clone response
const responseToCache = response.clone();
// Cache the fetched response
caches.open(CACHE_NAME)
.then((cache) => {
cache.put(event.request, responseToCache);
});
return response;
});
})
);
});
Register Service Worker:
main.js:
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
.then((registration) => {
console.log('SW registered:', registration.scope);
})
.catch((error) => {
console.log('SW registration failed:', error);
});
});
}
Step 3: Caching Strategies
Different strategies for different content:
1. Cache First (Static Assets)
Best for: Images, CSS, JS that don’t change often
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request)
.then((response) => {
return response || fetch(event.request);
})
);
});
2. Network First (Dynamic Content)
Best for: API calls, user-generated content
self.addEventListener('fetch', (event) => {
event.respondWith(
fetch(event.request)
.then((response) => {
// Cache the response
const responseClone = response.clone();
caches.open(CACHE_NAME)
.then((cache) => cache.put(event.request, responseClone));
return response;
})
.catch(() => {
// Network failed, try cache
return caches.match(event.request);
})
);
});
3. Stale While Revalidate
Best for: Content that updates occasionally
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.open(CACHE_NAME).then((cache) => {
return cache.match(event.request).then((cachedResponse) => {
const fetchPromise = fetch(event.request).then((networkResponse) => {
cache.put(event.request, networkResponse.clone());
return networkResponse;
});
// Return cached version immediately, update in background
return cachedResponse || fetchPromise;
});
})
);
});
4. Network Only
Best for: POST requests, analytics
self.addEventListener('fetch', (event) => {
// Don't cache, just fetch
event.respondWith(fetch(event.request));
});
5. Cache Only
Best for: App shell (after initial cache)
self.addEventListener('fetch', (event) => {
event.respondWith(caches.match(event.request));
});
Advanced: Route-Based Caching
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// API calls: Network first
if (url.pathname.startsWith('/api/')) {
event.respondWith(networkFirst(request));
}
// Images: Cache first
else if (request.destination === 'image') {
event.respondWith(cacheFirst(request));
}
// HTML: Stale while revalidate
else if (request.destination === 'document') {
event.respondWith(staleWhileRevalidate(request));
}
// Everything else: Network first
else {
event.respondWith(networkFirst(request));
}
});
async function cacheFirst(request) {
const cached = await caches.match(request);
return cached || fetch(request);
}
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 (error) {
return caches.match(request);
}
}
async function staleWhileRevalidate(request) {
const cache = await caches.open(CACHE_NAME);
const cached = await cache.match(request);
const fetchPromise = fetch(request).then((response) => {
cache.put(request, response.clone());
return response;
});
return cached || fetchPromise;
}
Step 4: Offline Functionality
Offline Page:
offline.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>You're Offline</title>
<style>
body {
font-family: system-ui;
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
margin: 0;
background: #f5f5f5;
}
.offline-container {
text-align: center;
}
h1 {
color: #666;
}
</style>
</head>
<body>
<div class="offline-container">
<h1>You're Offline</h1>
<p>Check your connection and try again.</p>
<button onclick="window.location.reload()">Retry</button>
</div>
</body>
</html>
Service Worker:
const OFFLINE_URL = '/offline.html';
// Cache offline page during install
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => cache.add(OFFLINE_URL))
);
});
// Show offline page when network fails
self.addEventListener('fetch', (event) => {
if (event.request.mode === 'navigate') {
event.respondWith(
fetch(event.request)
.catch(() => {
return caches.match(OFFLINE_URL);
})
);
}
});
Offline Data Persistence:
IndexedDB for Large Data:
// Store data offline
async function saveOffline(data) {
const db = await openDB('my-pwa-db', 1, {
upgrade(db) {
db.createObjectStore('posts', { keyPath: 'id' });
}
});
const tx = db.transaction('posts', 'readwrite');
await tx.store.put(data);
await tx.done;
}
// Retrieve data
async function getOfflineData() {
const db = await openDB('my-pwa-db', 1);
return db.getAll('posts');
}
Background Sync:
// Register sync when user posts while offline
async function savePost(postData) {
if ('serviceWorker' in navigator && 'SyncManager' in window) {
const registration = await navigator.serviceWorker.ready;
// Save post to IndexedDB
await saveOffline(postData);
// Register sync
await registration.sync.register('sync-posts');
showNotification('Post will be uploaded when online');
} else {
// Fallback: try immediate post
await fetch('/api/posts', {
method: 'POST',
body: JSON.stringify(postData)
});
}
}
// In service worker
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-posts') {
event.waitUntil(syncPosts());
}
});
async function syncPosts() {
const posts = await getOfflinePosts();
for (const post of posts) {
try {
await fetch('/api/posts', {
method: 'POST',
body: JSON.stringify(post)
});
// Success - remove from offline store
await deleteOfflinePost(post.id);
} catch (error) {
// Failed - will retry on next sync
console.error('Failed to sync post:', error);
}
}
}
Step 5: Push Notifications
Request Permission:
async function requestNotificationPermission() {
const permission = await Notification.requestPermission();
if (permission === 'granted') {
console.log('Notification permission granted');
await subscribeUserToPush();
} else {
console.log('Notification permission denied');
}
}
async function subscribeUserToPush() {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(PUBLIC_VAPID_KEY)
});
// Send subscription to server
await fetch('/api/push-subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(subscription)
});
}
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding)
.replace(/\-/g, '+')
.replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
Handle Push Events (Service Worker):
self.addEventListener('push', (event) => {
const data = event.data.json();
const options = {
body: data.body,
icon: '/icons/icon-192x192.png',
badge: '/icons/badge-72x72.png',
vibrate: [200, 100, 200],
data: {
url: data.url
},
actions: [
{
action: 'view',
title: 'View'
},
{
action: 'close',
title: 'Close'
}
]
};
event.waitUntil(
self.registration.showNotification(data.title, options)
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
if (event.action === 'view') {
event.waitUntil(
clients.openWindow(event.notification.data.url)
);
}
});
Server-Side (Node.js with web-push):
const webpush = require('web-push');
// Set VAPID keys
webpush.setVapidDetails(
'mailto:your-email@example.com',
PUBLIC_VAPID_KEY,
PRIVATE_VAPID_KEY
);
// Send notification
async function sendPushNotification(subscription, payload) {
try {
await webpush.sendNotification(subscription, JSON.stringify(payload));
console.log('Push notification sent');
} catch (error) {
console.error('Error sending push:', error);
}
}
// Example usage
app.post('/api/push-subscribe', (req, res) => {
const subscription = req.body;
// Save subscription to database
saveSubscription(subscription);
res.json({ success: true });
});
// Send notification to all subscribers
app.post('/api/notify-all', async (req, res) => {
const subscriptions = await getAllSubscriptions();
const payload = {
title: 'New Update!',
body: 'Check out our latest features',
url: '/updates'
};
const promises = subscriptions.map(sub =>
sendPushNotification(sub, payload)
);
await Promise.all(promises);
res.json({ sent: subscriptions.length });
});
Step 6: Install Prompt
Detect Install Capability:
let deferredPrompt;
window.addEventListener('beforeinstallprompt', (event) => {
// Prevent default install prompt
event.preventDefault();
// Save event for later
deferredPrompt = event;
// Show custom install button
document.getElementById('install-button').style.display = 'block';
});
// Custom install button click
document.getElementById('install-button').addEventListener('click', async () => {
if (!deferredPrompt) {
return;
}
// Show install prompt
deferredPrompt.prompt();
// Wait for user choice
const { outcome } = await deferredPrompt.userChoice;
console.log(`User ${outcome} the install prompt`);
// Clear prompt
deferredPrompt = null;
// Hide install button
document.getElementById('install-button').style.display = 'none';
});
// Detect if already installed
window.addEventListener('appinstalled', (event) => {
console.log('PWA installed');
// Hide install button
document.getElementById('install-button').style.display = 'none';
// Track installation
analytics.track('pwa_installed');
});
// Check if running as installed PWA
if (window.matchMedia('(display-mode: standalone)').matches) {
console.log('Running as installed PWA');
}
Step 7: App Shell Architecture
Concept: Minimal HTML/CSS/JS to display UI instantly, load content asynchronously.
index.html (App Shell):
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>My PWA</title>
<link rel="manifest" href="/manifest.json">
<link rel="stylesheet" href="/styles/app-shell.css">
<!-- Theme color -->
<meta name="theme-color" content="#4285f4">
<!-- iOS -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="My PWA">
<link rel="apple-touch-icon" href="/icons/icon-152x152.png">
</head>
<body>
<!-- App Shell (loads instantly from cache) -->
<header>
<h1>My PWA</h1>
<nav>
<a href="/">Home</a>
<a href="/about">About</a>
</nav>
</header>
<main id="content">
<!-- Loading placeholder -->
<div class="loader">Loading...</div>
</main>
<footer>
<p>© 2022 My PWA</p>
</footer>
<script src="/js/app.js"></script>
</body>
</html>
app-shell.css (Cached, loads instantly):
/* Minimal CSS for instant paint */
body {
margin: 0;
font-family: system-ui;
}
header {
background: #4285f4;
color: white;
padding: 1rem;
}
.loader {
text-align: center;
padding: 2rem;
color: #999;
}
app.js (Load content):
async function loadContent() {
const content = document.getElementById('content');
try {
const response = await fetch('/api/content');
const data = await response.json();
// Replace loader with actual content
content.innerHTML = renderContent(data);
} catch (error) {
content.innerHTML = '<p>Failed to load content</p>';
}
}
loadContent();
Service Worker (Cache App Shell):
const APP_SHELL = [
'/',
'/styles/app-shell.css',
'/js/app.js',
'/icons/icon-192x192.png'
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open('app-shell-v1')
.then((cache) => cache.addAll(APP_SHELL))
);
});
Step 8: Performance Optimization
Lazy Loading:
// Lazy load images
if ('IntersectionObserver' in window) {
const imageObserver = new IntersectionObserver((entries, observer) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
img.classList.remove('lazy');
observer.unobserve(img);
}
});
});
document.querySelectorAll('img.lazy').forEach((img) => {
imageObserver.observe(img);
});
}
Code Splitting:
// Dynamic import
button.addEventListener('click', async () => {
const { heavy Function } = await import('./heavy-module.js');
heavyFunction();
});
Prefetching:
<!-- Prefetch next page -->
<link rel="prefetch" href="/next-page.html">
<!-- Preload critical resources -->
<link rel="preload" href="/fonts/main.woff2" as="font" crossorigin>
Step 9: Testing Your PWA
Lighthouse Audit:
# Install Lighthouse CLI
npm install -g lighthouse
# Run audit
lighthouse https://your-pwa.com --view
# Check PWA score (should be 100)
PWA Criteria (Lighthouse):
Registers a service worker
Responds with 200 when offline
Has a web app manifest
Uses HTTPS
Redirects HTTP to HTTPS
Configured for custom splash screen
Sets theme color
Content sized correctly for viewport
Has valid apple-touch-icon
Chrome DevTools:
1. Open DevTools
2. Application tab
3. Check:
- Manifest
- Service Workers
- Cache Storage
- IndexedDB
4. Test offline:
- Network tab → Offline checkbox
- Reload page
- Should work!
Real-World PWA: To-Do App
Complete Example:
manifest.json:
{
"name": "PWA To-Do App",
"short_name": "To-Do",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#4285f4",
"icons": [
{
"src": "/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
index.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>To-Do PWA</title>
<link rel="manifest" href="/manifest.json">
<style>
body {
font-family: system-ui;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.todo-item {
padding: 10px;
border-bottom: 1px solid #eee;
display: flex;
justify-content: space-between;
}
.todo-item.completed {
text-decoration: line-through;
opacity: 0.6;
}
</style>
</head>
<body>
<h1>To-Do List</h1>
<form id="add-todo-form">
<input type="text" id="todo-input" placeholder="What needs to be done?" required>
<button type="submit">Add</button>
</form>
<div id="todos"></div>
<script src="/js/app.js"></script>
<script src="/js/sw-register.js"></script>
</body>
</html>
app.js:
let todos = [];
// Load todos from IndexedDB
async function loadTodos() {
const db = await openDB();
todos = await db.getAll('todos');
renderTodos();
}
// Open IndexedDB
function openDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open('todo-pwa', 1);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
request.onupgradeneeded = (event) => {
const db = event.target.result;
db.createObjectStore('todos', { keyPath: 'id', autoIncrement: true });
};
});
}
// Add todo
document.getElementById('add-todo-form').addEventListener('submit', async (e) => {
e.preventDefault();
const input = document.getElementById('todo-input');
const text = input.value.trim();
if (!text) return;
const todo = {
text,
completed: false,
createdAt: Date.now()
};
// Save to IndexedDB
const db = await openDB();
const tx = db.transaction('todos', 'readwrite');
const id = await tx.objectStore('todos').add(todo);
await tx.complete;
todo.id = id;
todos.push(todo);
renderTodos();
input.value = '';
});
// Render todos
function renderTodos() {
const container = document.getElementById('todos');
container.innerHTML = todos.map(todo => `
<div class="todo-item ${todo.completed ? 'completed' : ''}">
<span onclick="toggleTodo(${todo.id})">${todo.text}</span>
<button onclick="deleteTodo(${todo.id})">Delete</button>
</div>
`).join('');
}
// Toggle todo
async function toggleTodo(id) {
const todo = todos.find(t => t.id === id);
todo.completed = !todo.completed;
const db = await openDB();
const tx = db.transaction('todos', 'readwrite');
await tx.objectStore('todos').put(todo);
await tx.complete;
renderTodos();
}
// Delete todo
async function deleteTodo(id) {
todos = todos.filter(t => t.id !== id);
const db = await openDB();
const tx = db.transaction('todos', 'readwrite');
await tx.objectStore('todos').delete(id);
await tx.complete;
renderTodos();
}
// Load on start
loadTodos();
sw-register.js:
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js')
.then(() => console.log('Service Worker registered'))
.catch((err) => console.error('SW registration failed', err));
}
sw.js:
const CACHE_NAME = 'todo-pwa-v1';
const urlsToCache = [
'/',
'/js/app.js',
'/js/sw-register.js',
'/icons/icon-192x192.png',
'/icons/icon-512x512.png'
];
// Install - cache assets
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => cache.addAll(urlsToCache))
);
});
// Activate - clean 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))
);
})
);
});
// Fetch - cache first
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request)
.then((response) => response || fetch(event.request))
);
});
Result:
- Works offline
- Installable
- Fast
- Data persists
PWA Checklist
Before Launch:
Manifest
- name, short_name, start_url
- display: standalone
- icons (192×192, 512×512)
- theme_color, background_color
Service Worker
- Registered successfully
- Caches app shell
- Handles offline requests
- Updates properly
HTTPS
- SSL certificate installed
- HTTP redirects to HTTPS
Responsive
- Mobile viewport configured
- Works on all screen sizes
Performance
- Fast initial load (< 3s)
- Fast Time to Interactive (< 5s)
- Smooth animations (60fps)
Offline
- Works offline
- Or shows meaningful offline page
Install
- Installable (passes criteria)
- Custom install prompt (optional)
- iOS meta tags
Testing
- Lighthouse score 90+
- Tested on multiple devices
- Tested offline functionality
Conclusion: PWAs Are the Future
Why PWAs Win:
vs. Mobile Websites:
- Work offline
- Install to home screen
- Push notifications
- Better performance
vs. Native Apps:
- No app store approval
- Instant updates
- Lower development cost
- Single codebase
- Discoverability (search engines)
When to Build PWA:
- Content-focused apps
- E-commerce
- News/media
- Social networks
- Productivity tools
When Native Still Wins:
- Need device-specific features
- Complex games
- AR/VR applications
- High-performance requirements
In 2022: PWAs are production-ready for most use cases.
Key Takeaways:
- PWAs combine best of web and native apps
- Service workers enable offline functionality
- Web app manifest makes apps installable
- Different caching strategies for different content
- Push notifications don’t require app store
- App shell architecture ensures fast loading
- IndexedDB enables offline data storage
- Background sync allows offline writes
- Lighthouse audits verify PWA quality
- PWAs work cross-platform with single codebase
Ready to build your PWA?
We’ve built PWAs for e-commerce, productivity, and social platforms. Free PWA consultation available.
[Schedule PWA Consultation →]