
Introduction
Modern users expect fast, reliable, and installable web experiences across devices. Progressive Web Apps (PWAs) bridge the gap between traditional websites and native applications by combining web technologies with app-like capabilities. Companies like Twitter, Starbucks, and Pinterest have seen significant improvements in engagement after launching PWAs, with faster load times and higher conversion rates. In this comprehensive guide, you will learn how to build a Progressive Web App from scratch, understand the core building blocks including service workers and web manifests, implement various caching strategies, and apply best practices to deliver a smooth and reliable user experience. By the end, you will know how to turn a standard web app into a performant, installable PWA with offline support.
What Makes an App a Progressive Web App
PWAs follow a set of principles rather than a strict framework. These principles focus on reliability, performance, and engagement, creating experiences that feel native while remaining accessible through the web.
• Reliable: Works offline or on slow networks through caching
• Fast: Loads quickly and responds instantly to user interactions
• Installable: Can be added to the home screen like a native app
• Engaging: Feels like a native app with immersive full-screen experience
• Secure: Runs exclusively over HTTPS
• Progressive: Works for every user regardless of browser choice
• Linkable: Can be shared via URLs without app store distribution
By following these principles, your web app becomes more resilient, user-friendly, and competitive with native applications.
Core Building Blocks of a PWA
To build a PWA from scratch, you need to understand its essential components and how they work together.
Project Structure
my-pwa/
├── index.html # Main HTML file
├── manifest.json # Web app manifest
├── service-worker.js # Service worker script
├── offline.html # Offline fallback page
├── css/
│ └── styles.css
├── js/
│ ├── app.js # Main application code
│ └── sw-register.js # Service worker registration
├── icons/
│ ├── icon-72.png
│ ├── icon-96.png
│ ├── icon-128.png
│ ├── icon-144.png
│ ├── icon-152.png
│ ├── icon-192.png
│ ├── icon-384.png
│ └── icon-512.png
└── images/
└── ...
Web App Manifest
The web app manifest is a JSON file that defines how your app appears when installed. It controls the app name, icons, theme colors, display mode, and launch behavior.
// manifest.json
{
"name": "My Progressive Web App",
"short_name": "MyPWA",
"description": "A fast, reliable, and installable web application",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "portrait-primary",
"background_color": "#ffffff",
"theme_color": "#317EFB",
"categories": ["productivity", "utilities"],
"icons": [
{
"src": "/icons/icon-72.png",
"sizes": "72x72",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-96.png",
"sizes": "96x96",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-128.png",
"sizes": "128x128",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-144.png",
"sizes": "144x144",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-152.png",
"sizes": "152x152",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/icon-384.png",
"sizes": "384x384",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"screenshots": [
{
"src": "/images/screenshot-wide.png",
"sizes": "1280x720",
"type": "image/png",
"form_factor": "wide"
},
{
"src": "/images/screenshot-narrow.png",
"sizes": "750x1334",
"type": "image/png",
"form_factor": "narrow"
}
],
"shortcuts": [
{
"name": "New Task",
"short_name": "New",
"url": "/tasks/new",
"icons": [{ "src": "/icons/new-task.png", "sizes": "192x192" }]
},
{
"name": "Today's Tasks",
"short_name": "Today",
"url": "/tasks/today",
"icons": [{ "src": "/icons/today.png", "sizes": "192x192" }]
}
],
"related_applications": [],
"prefer_related_applications": false
}
HTML Setup
Link the manifest and add meta tags for PWA support.
<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="A fast, reliable, and installable web application">
<!-- PWA Meta Tags -->
<meta name="theme-color" content="#317EFB">
<meta name="mobile-web-app-capable" content="yes">
<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">
<!-- Manifest -->
<link rel="manifest" href="/manifest.json">
<!-- Apple Touch Icons -->
<link rel="apple-touch-icon" href="/icons/icon-152.png">
<link rel="apple-touch-icon" sizes="180x180" href="/icons/icon-192.png">
<!-- Favicon -->
<link rel="icon" type="image/png" sizes="32x32" href="/icons/icon-32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/icons/icon-16.png">
<title>My Progressive Web App</title>
<link rel="stylesheet" href="/css/styles.css">
</head>
<body>
<header>
<h1>My PWA</h1>
<button id="install-btn" style="display: none;">Install App</button>
</header>
<main id="app">
<!-- Application content -->
</main>
<div id="offline-indicator" class="hidden">
You are currently offline
</div>
<script src="/js/app.js"></script>
<script src="/js/sw-register.js"></script>
</body>
</html>
Service Worker Fundamentals
The service worker is a JavaScript file that runs in the background, separate from the main page. It intercepts network requests, manages caching, and enables offline functionality.
Service Worker Lifecycle
// service-worker.js
const CACHE_NAME = 'my-pwa-cache-v1';
const STATIC_CACHE = 'static-v1';
const DYNAMIC_CACHE = 'dynamic-v1';
// Assets to cache immediately during installation
const STATIC_ASSETS = [
'/',
'/index.html',
'/offline.html',
'/css/styles.css',
'/js/app.js',
'/icons/icon-192.png',
'/icons/icon-512.png'
];
// Install Event - Cache static assets
self.addEventListener('install', (event) => {
console.log('[Service Worker] Installing...');
event.waitUntil(
caches.open(STATIC_CACHE)
.then((cache) => {
console.log('[Service Worker] Caching static assets');
return cache.addAll(STATIC_ASSETS);
})
.then(() => {
// Force the waiting service worker to become active
return self.skipWaiting();
})
);
});
// Activate Event - Clean up old caches
self.addEventListener('activate', (event) => {
console.log('[Service Worker] Activating...');
event.waitUntil(
caches.keys()
.then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name !== STATIC_CACHE && name !== DYNAMIC_CACHE)
.map((name) => {
console.log('[Service Worker] Deleting old cache:', name);
return caches.delete(name);
})
);
})
.then(() => {
// Take control of all clients immediately
return self.clients.claim();
})
);
});
// Fetch Event - Intercept network requests
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// Skip non-GET requests
if (request.method !== 'GET') {
return;
}
// Skip cross-origin requests (except for CDN assets)
if (url.origin !== location.origin) {
return;
}
// Handle different types of requests
if (isStaticAsset(url.pathname)) {
event.respondWith(cacheFirst(request));
} else if (isApiRequest(url.pathname)) {
event.respondWith(networkFirst(request));
} else {
event.respondWith(staleWhileRevalidate(request));
}
});
function isStaticAsset(pathname) {
return /\.(css|js|png|jpg|jpeg|gif|svg|woff2?|ttf|eot)$/.test(pathname);
}
function isApiRequest(pathname) {
return pathname.startsWith('/api/');
}
Registering the Service Worker
// js/sw-register.js
if ('serviceWorker' in navigator) {
window.addEventListener('load', async () => {
try {
const registration = await navigator.serviceWorker.register('/service-worker.js', {
scope: '/'
});
console.log('Service Worker registered:', registration.scope);
// Handle updates
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
// New content available, prompt user to refresh
showUpdateNotification();
}
});
});
} catch (error) {
console.error('Service Worker registration failed:', error);
}
});
// Listen for controller change and reload
let refreshing = false;
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (!refreshing) {
refreshing = true;
window.location.reload();
}
});
}
function showUpdateNotification() {
const updateBanner = document.createElement('div');
updateBanner.className = 'update-banner';
updateBanner.innerHTML = `
A new version is available!
`;
document.body.appendChild(updateBanner);
}
Caching Strategies
Choosing the right caching strategy is critical for balancing performance, freshness, and reliability.
Cache-First Strategy
Best for static assets that rarely change like CSS, JavaScript, images, and fonts.
// Cache-First: Try cache, fall back to network
async function cacheFirst(request) {
const cachedResponse = await caches.match(request);
if (cachedResponse) {
console.log('[Cache-First] Serving from cache:', request.url);
return cachedResponse;
}
console.log('[Cache-First] Fetching from network:', request.url);
try {
const networkResponse = await fetch(request);
// Cache the new response
if (networkResponse.ok) {
const cache = await caches.open(STATIC_CACHE);
cache.put(request, networkResponse.clone());
}
return networkResponse;
} catch (error) {
// Return offline fallback for navigation requests
if (request.mode === 'navigate') {
return caches.match('/offline.html');
}
throw error;
}
}
Network-First Strategy
Best for API requests and dynamic content that needs to be fresh.
// Network-First: Try network, fall back to cache
async function networkFirst(request) {
try {
console.log('[Network-First] Fetching from network:', request.url);
const networkResponse = await fetch(request);
// Cache successful responses
if (networkResponse.ok) {
const cache = await caches.open(DYNAMIC_CACHE);
cache.put(request, networkResponse.clone());
}
return networkResponse;
} catch (error) {
console.log('[Network-First] Network failed, trying cache:', request.url);
const cachedResponse = await caches.match(request);
if (cachedResponse) {
return cachedResponse;
}
// Return error response for API requests
return new Response(
JSON.stringify({ error: 'Network unavailable', offline: true }),
{
status: 503,
headers: { 'Content-Type': 'application/json' }
}
);
}
}
Stale-While-Revalidate Strategy
Best for content that updates frequently but stale data is acceptable temporarily.
// Stale-While-Revalidate: Serve cache immediately, update in background
async function staleWhileRevalidate(request) {
const cache = await caches.open(DYNAMIC_CACHE);
const cachedResponse = await cache.match(request);
// Fetch from network in background
const networkPromise = fetch(request)
.then((response) => {
if (response.ok) {
cache.put(request, response.clone());
}
return response;
})
.catch(() => null);
// Return cached response immediately if available
if (cachedResponse) {
console.log('[SWR] Serving stale from cache:', request.url);
return cachedResponse;
}
// Otherwise wait for network
console.log('[SWR] No cache, waiting for network:', request.url);
const networkResponse = await networkPromise;
if (networkResponse) {
return networkResponse;
}
// Fallback for navigation
if (request.mode === 'navigate') {
return caches.match('/offline.html');
}
throw new Error('No cached or network response available');
}
Cache-Only and Network-Only
// Cache-Only: Never go to network
async function cacheOnly(request) {
const cachedResponse = await caches.match(request);
return cachedResponse || new Response('Not found in cache', { status: 404 });
}
// Network-Only: Never use cache (for real-time data)
async function networkOnly(request) {
return fetch(request);
}
Offline Fallback Page
Always provide a helpful fallback page when users are offline.
<!-- 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 - My PWA</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
text-align: center;
padding: 20px;
}
.offline-icon {
font-size: 80px;
margin-bottom: 20px;
}
h1 {
font-size: 2rem;
margin-bottom: 10px;
}
p {
font-size: 1.1rem;
opacity: 0.9;
max-width: 400px;
margin-bottom: 30px;
}
.retry-btn {
background: white;
color: #667eea;
border: none;
padding: 12px 30px;
font-size: 1rem;
border-radius: 25px;
cursor: pointer;
transition: transform 0.2s;
}
.retry-btn:hover {
transform: scale(1.05);
}
.cached-pages {
margin-top: 40px;
text-align: left;
}
.cached-pages h2 {
font-size: 1rem;
margin-bottom: 10px;
}
.cached-pages ul {
list-style: none;
}
.cached-pages a {
color: white;
text-decoration: underline;
}
</style>
</head>
<body>
<div class="offline-icon">📡</div>
<h1>You're Offline</h1>
<p>It looks like you've lost your internet connection. Don't worry, some features are still available offline.</p>
<button class="retry-btn" onclick="window.location.reload()">Try Again</button>
<div class="cached-pages" id="cached-pages"></div>
<script>
// Show available cached pages
if ('caches' in window) {
caches.open('dynamic-v1').then(cache => {
cache.keys().then(keys => {
const pages = keys.filter(req => req.url.endsWith('/') || req.url.endsWith('.html'));
if (pages.length > 0) {
const container = document.getElementById('cached-pages');
container.innerHTML = `
<h2>Available offline:</h2>
<ul>
${pages.map(req => `<li><a href="${new URL(req.url).pathname}">${new URL(req.url).pathname}</a></li>`).join('')}
</ul>
`;
}
});
});
}
</script>
</body>
</html>
Making the App Installable
Installability improves user engagement by allowing your PWA to behave like a native app on the home screen.
Install Criteria
For a PWA to be installable, it must meet these requirements:
• Served over HTTPS
• Has a valid web app manifest with required fields
• Has a registered service worker
• Meets engagement heuristics (varies by browser)
Handling the Install Prompt
// js/app.js
let deferredPrompt;
const installBtn = document.getElementById('install-btn');
// Listen for the beforeinstallprompt event
window.addEventListener('beforeinstallprompt', (event) => {
// Prevent the mini-infobar from appearing
event.preventDefault();
// Store the event for later use
deferredPrompt = event;
// Show custom install button
installBtn.style.display = 'block';
console.log('Install prompt ready');
});
// Handle install button click
installBtn.addEventListener('click', async () => {
if (!deferredPrompt) {
return;
}
// Show the install prompt
deferredPrompt.prompt();
// Wait for user response
const { outcome } = await deferredPrompt.userChoice;
console.log(`User response: ${outcome}`);
// Clear the deferred prompt
deferredPrompt = null;
// Hide the install button
installBtn.style.display = 'none';
});
// Track successful installation
window.addEventListener('appinstalled', (event) => {
console.log('App installed successfully');
// Hide install button
installBtn.style.display = 'none';
// Track installation in analytics
if (typeof gtag !== 'undefined') {
gtag('event', 'pwa_install');
}
});
// Check if app is running in standalone mode
function isRunningStandalone() {
return window.matchMedia('(display-mode: standalone)').matches ||
window.navigator.standalone === true;
}
if (isRunningStandalone()) {
console.log('Running in standalone mode');
// Apply standalone-specific behavior
document.body.classList.add('standalone');
}
Background Sync
Background sync allows you to defer actions until the user has stable connectivity.
// In your app code - queue a sync when offline
async function submitForm(data) {
try {
await fetch('/api/submit', {
method: 'POST',
body: JSON.stringify(data),
headers: { 'Content-Type': 'application/json' }
});
showSuccess('Submitted successfully!');
} catch (error) {
// Store data for later and register sync
await saveToIndexedDB('pending-submissions', data);
if ('serviceWorker' in navigator && 'sync' in window.SyncManager) {
const registration = await navigator.serviceWorker.ready;
await registration.sync.register('submit-form');
showInfo('Saved offline. Will submit when back online.');
}
}
}
// In service-worker.js - handle sync event
self.addEventListener('sync', (event) => {
if (event.tag === 'submit-form') {
event.waitUntil(submitPendingData());
}
});
async function submitPendingData() {
const db = await openIndexedDB();
const pending = await getAllFromStore(db, 'pending-submissions');
for (const item of pending) {
try {
await fetch('/api/submit', {
method: 'POST',
body: JSON.stringify(item.data),
headers: { 'Content-Type': 'application/json' }
});
await deleteFromStore(db, 'pending-submissions', item.id);
} catch (error) {
// Will retry on next sync
console.error('Sync failed, will retry:', error);
}
}
}
Push Notifications
Push notifications re-engage users even when the browser is closed.
// Request notification permission
async function requestNotificationPermission() {
if (!('Notification' in window)) {
console.log('Notifications not supported');
return false;
}
if (Notification.permission === 'granted') {
return true;
}
if (Notification.permission !== 'denied') {
const permission = await Notification.requestPermission();
return permission === 'granted';
}
return false;
}
// Subscribe to push notifications
async function subscribeToPush() {
const hasPermission = await requestNotificationPermission();
if (!hasPermission) return null;
const registration = await navigator.serviceWorker.ready;
// Get VAPID public key from server
const response = await fetch('/api/push/vapid-public-key');
const { publicKey } = await response.json();
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey)
});
// Send subscription to server
await fetch('/api/push/subscribe', {
method: 'POST',
body: JSON.stringify(subscription),
headers: { 'Content-Type': 'application/json' }
});
return 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);
return Uint8Array.from([...rawData].map(char => char.charCodeAt(0)));
}
// service-worker.js - Handle push events
self.addEventListener('push', (event) => {
const data = event.data?.json() || {
title: 'New Notification',
body: 'You have a new message',
icon: '/icons/icon-192.png'
};
const options = {
body: data.body,
icon: data.icon || '/icons/icon-192.png',
badge: '/icons/badge-72.png',
vibrate: [100, 50, 100],
data: {
url: data.url || '/'
},
actions: [
{ action: 'open', title: 'Open' },
{ action: 'dismiss', title: 'Dismiss' }
]
};
event.waitUntil(
self.registration.showNotification(data.title, options)
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
if (event.action === 'dismiss') {
return;
}
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true })
.then((clientList) => {
// Focus existing window if available
for (const client of clientList) {
if (client.url === event.notification.data.url && 'focus' in client) {
return client.focus();
}
}
// Open new window
if (clients.openWindow) {
return clients.openWindow(event.notification.data.url);
}
})
);
});
Testing and Auditing Your PWA
Testing ensures your PWA meets quality standards and provides a great user experience.
Lighthouse Audit
# Run Lighthouse from command line
npx lighthouse https://your-pwa.com --view
# Generate JSON report
npx lighthouse https://your-pwa.com --output=json --output-path=./lighthouse-report.json
# PWA-specific audit
npx lighthouse https://your-pwa.com --only-categories=pwa
Manual Testing Checklist
• Test offline behavior by enabling airplane mode
• Verify all cached pages load without network
• Test install prompt on mobile and desktop
• Verify push notifications work
• Check that the app launches from home screen correctly
• Test on multiple browsers (Chrome, Safari, Firefox, Edge)
• Verify service worker updates correctly
Common Mistakes to Avoid
Although PWAs are powerful, they come with common pitfalls that can degrade user experience.
1. Over-Caching Content
// ❌ Bad: Caching everything forever
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then(response => response || fetch(event.request))
);
});
// ✅ Good: Use appropriate strategies for different content
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
if (isStaticAsset(url.pathname)) {
event.respondWith(cacheFirst(event.request));
} else if (isApiRequest(url.pathname)) {
event.respondWith(networkFirst(event.request));
}
});
2. Ignoring Service Worker Updates
// ❌ Bad: No update handling - users stuck with old version
navigator.serviceWorker.register('/sw.js');
// ✅ Good: Handle updates and notify users
navigator.serviceWorker.register('/sw.js').then(registration => {
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
showUpdateBanner();
}
});
});
});
3. Missing Offline Fallback
// ❌ Bad: No fallback - shows browser error page
self.addEventListener('fetch', (event) => {
event.respondWith(fetch(event.request));
});
// ✅ Good: Provide helpful fallback
self.addEventListener('fetch', (event) => {
event.respondWith(
fetch(event.request)
.catch(() => {
if (event.request.mode === 'navigate') {
return caches.match('/offline.html');
}
})
);
});
4. Not Versioning Cache
// ❌ Bad: Same cache name forever
const CACHE_NAME = 'my-cache';
// ✅ Good: Version your cache
const CACHE_NAME = 'my-cache-v2';
// Clean up old versions in activate event
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then(names =>
Promise.all(
names.filter(name => name !== CACHE_NAME).map(name => caches.delete(name))
)
)
);
});
5. Blocking App Shell Loading
// ❌ Bad: Waiting for all data before rendering
async function init() {
const data = await fetchAllData();
renderApp(data);
}
// ✅ Good: Show app shell immediately, load data progressively
function init() {
renderAppShell(); // Instant
loadCriticalData().then(updateUI);
loadSecondaryData().then(updateSidebar);
}
Conclusion
Progressive Web Apps offer a powerful way to deliver fast, reliable, and installable experiences using standard web technologies. By combining service workers for offline support and caching, a proper manifest for installability, and modern APIs for push notifications and background sync, you can transform a traditional web app into a modern PWA that rivals native applications. The key is choosing the right caching strategies for different types of content, handling service worker updates gracefully, and always providing offline fallbacks.
Companies of all sizes have seen significant improvements in user engagement, conversion rates, and performance metrics after implementing PWAs. With broad browser support and continuously improving capabilities, PWAs represent the future of web development.
If you want to improve your frontend and delivery workflows, read CI/CD for Node.js Projects Using GitHub Actions. For efficient backend file handling that pairs well with PWA architectures, see Using Node.js Streams for Efficient File Processing. To learn about modern JavaScript features useful in PWA development, check out Modern ECMAScript Features You Might Have Missed. You can also explore the Google PWA documentation and the MDN PWA guides to deepen your understanding. With the right setup, PWAs provide a modern, app-like experience directly from the web.