Progressive Web Apps (PWA): The Complete 2026 Guide
Learn how to build Progressive Web Apps that work offline, load instantly, and feel like native apps. Includes service workers, caching strategies, and push notifications.
Progressive Web Apps have evolved from a buzzword to a production-ready technology powering millions of applications. In 2026, PWAs deliver native-like experiences that work offline, load instantly, and engage users like never before. Here's everything you need to know.
Related reading: Check out our guides on serverless edge computing and React performance for more web performance insights.
What Makes a PWA in 2026#
Core Characteristics#
Reliable: Loads instantly and works offline Fast: Responds quickly to user interactions Engaging: Feels like a native app on any device Installable: Can be added to home screen Discoverable: Identifiable as an application by search engines Safe: Served via HTTPS Responsive: Works on any device and screen size Progressive: Works for every user, regardless of browser
The PWA Advantage#
User benefits:
- Instant loading (< 1s)
- Offline functionality
- Push notifications
- Home screen installation
- Full-screen experience
- Background sync
Business benefits:
- 50% higher engagement rates
- 3x faster load times
- 25% increase in conversions
- Lower development costs (one codebase)
- Better SEO rankings
- Reduced app store dependencies
Building Your First PWA#
1. Web App Manifest#
// public/manifest.json
{
"name": "My Progressive Web App",
"short_name": "MyPWA",
"description": "A powerful progressive web application",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#4f46e5",
"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 Document",
"short_name": "New",
"description": "Create a new document",
"url": "/new",
"icons": [{ "src": "/icons/new.png", "sizes": "96x96" }]
},
{
"name": "Recent",
"short_name": "Recent",
"description": "View recent documents",
"url": "/recent",
"icons": [{ "src": "/icons/recent.png", "sizes": "96x96" }]
}
],
"share_target": {
"action": "/share",
"method": "POST",
"enctype": "multipart/form-data",
"params": {
"title": "title",
"text": "text",
"url": "url",
"files": [
{
"name": "file",
"accept": ["image/*", "video/*"]
}
]
}
}
}
Add to HTML:
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#4f46e5">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="MyPWA">
<link rel="apple-touch-icon" href="/icons/icon-192x192.png">
2. Service Worker Basics#
// public/sw.js
const CACHE_NAME = 'my-pwa-v1';
const RUNTIME_CACHE = 'runtime-cache-v1';
// Assets to cache on install
const PRECACHE_ASSETS = [
'/',
'/index.html',
'/styles/main.css',
'/scripts/app.js',
'/images/logo.png',
'/offline.html'
];
// Install event - cache essential assets
self.addEventListener('install', (event) => {
console.log('Service Worker installing...');
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => {
console.log('Caching app shell');
return cache.addAll(PRECACHE_ASSETS);
})
.then(() => 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 !== CACHE_NAME && name !== RUNTIME_CACHE)
.map(name => caches.delete(name))
);
}).then(() => self.clients.claim())
);
});
// Fetch event - serve from cache, fallback to network
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// Skip cross-origin requests
if (url.origin !== location.origin) {
return;
}
event.respondWith(
caches.match(request)
.then(cachedResponse => {
if (cachedResponse) {
return cachedResponse;
}
return fetch(request).then(response => {
// Don't cache non-successful responses
if (!response || response.status !== 200 || response.type !== 'basic') {
return response;
}
// Clone the response
const responseToCache = response.clone();
caches.open(RUNTIME_CACHE).then(cache => {
cache.put(request, responseToCache);
});
return response;
});
})
.catch(() => {
// Return offline page for navigation requests
if (request.mode === 'navigate') {
return caches.match('/offline.html');
}
})
);
});
Register Service Worker:
// app.js
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
.then(registration => {
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 service worker available
showUpdateNotification();
}
});
});
})
.catch(error => {
console.error('SW registration failed:', error);
});
});
}
function showUpdateNotification() {
const notification = document.createElement('div');
notification.className = 'update-notification';
notification.innerHTML = `
<p>New version available!</p>
<button onclick="window.location.reload()">Update</button>
`;
document.body.appendChild(notification);
}
Advanced Caching Strategies#
1. Cache-First Strategy#
// Best for: Static assets that rarely change
self.addEventListener('fetch', (event) => {
if (event.request.url.match(/\.(css|js|png|jpg|svg|woff2)$/)) {
event.respondWith(
caches.match(event.request)
.then(response => response || fetch(event.request))
);
}
});
2. Network-First Strategy#
// Best for: API calls and dynamic content
self.addEventListener('fetch', (event) => {
if (event.request.url.includes('/api/')) {
event.respondWith(
fetch(event.request)
.then(response => {
const responseClone = response.clone();
caches.open(RUNTIME_CACHE).then(cache => {
cache.put(event.request, responseClone);
});
return response;
})
.catch(() => caches.match(event.request))
);
}
});
3. Stale-While-Revalidate#
// Best for: Frequently updated content
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.open(RUNTIME_CACHE).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 cachedResponse || fetchPromise;
});
})
);
});
4. Cache with Network Fallback#
// Best for: Essential resources
async function cacheWithNetworkFallback(request) {
try {
const cache = await caches.open(CACHE_NAME);
const cached = await cache.match(request);
if (cached) {
return cached;
}
const response = await fetch(request);
await cache.put(request, response.clone());
return response;
} catch (error) {
console.error('Fetch failed:', error);
return caches.match('/offline.html');
}
}
Offline Functionality#
IndexedDB for Data Storage#
// db.js - IndexedDB wrapper
class PWADatabase {
constructor(dbName, version = 1) {
this.dbName = dbName;
this.version = version;
this.db = null;
}
async init() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.version);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
this.db = request.result;
resolve(this.db);
};
request.onupgradeneeded = (event) => {
const db = event.target.result;
// Create object stores
if (!db.objectStoreNames.contains('posts')) {
const postStore = db.createObjectStore('posts', { keyPath: 'id' });
postStore.createIndex('date', 'date', { unique: false });
postStore.createIndex('category', 'category', { unique: false });
}
if (!db.objectStoreNames.contains('drafts')) {
db.createObjectStore('drafts', { keyPath: 'id', autoIncrement: true });
}
if (!db.objectStoreNames.contains('sync-queue')) {
db.createObjectStore('sync-queue', { keyPath: 'id', autoIncrement: true });
}
};
});
}
async add(storeName, data) {
const tx = this.db.transaction(storeName, 'readwrite');
const store = tx.objectStore(storeName);
return store.add(data);
}
async get(storeName, key) {
const tx = this.db.transaction(storeName, 'readonly');
const store = tx.objectStore(storeName);
return store.get(key);
}
async getAll(storeName) {
const tx = this.db.transaction(storeName, 'readonly');
const store = tx.objectStore(storeName);
return store.getAll();
}
async update(storeName, data) {
const tx = this.db.transaction(storeName, 'readwrite');
const store = tx.objectStore(storeName);
return store.put(data);
}
async delete(storeName, key) {
const tx = this.db.transaction(storeName, 'readwrite');
const store = tx.objectStore(storeName);
return store.delete(key);
}
async clear(storeName) {
const tx = this.db.transaction(storeName, 'readwrite');
const store = tx.objectStore(storeName);
return store.clear();
}
}
// Usage
const db = new PWADatabase('my-pwa-db');
await db.init();
// Save data offline
await db.add('posts', {
id: 1,
title: 'My Post',
content: 'Post content',
date: new Date()
});
// Retrieve data
const post = await db.get('posts', 1);
const allPosts = await db.getAll('posts');
Background Sync#
// Register background sync
async function saveDataWithSync(data) {
try {
// Try to save immediately
await fetch('/api/save', {
method: 'POST',
body: JSON.stringify(data),
headers: { 'Content-Type': 'application/json' }
});
} catch (error) {
// Save to IndexedDB for later sync
await db.add('sync-queue', {
url: '/api/save',
method: 'POST',
data: data,
timestamp: Date.now()
});
// Register sync
if ('sync' in registration) {
await registration.sync.register('sync-data');
}
}
}
// Service worker - handle sync event
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-data') {
event.waitUntil(syncData());
}
});
async function syncData() {
const db = new PWADatabase('my-pwa-db');
await db.init();
const queue = await db.getAll('sync-queue');
for (const item of queue) {
try {
await fetch(item.url, {
method: item.method,
body: JSON.stringify(item.data),
headers: { 'Content-Type': 'application/json' }
});
// Remove from queue on success
await db.delete('sync-queue', item.id);
} catch (error) {
console.error('Sync failed for item:', item.id);
}
}
}
Push Notifications#
Setting Up Push Notifications#
// Request permission
async function requestNotificationPermission() {
const permission = await Notification.requestPermission();
if (permission === 'granted') {
console.log('Notification permission granted');
await subscribeToPush();
} else {
console.log('Notification permission denied');
}
}
// Subscribe to push notifications
async function subscribeToPush() {
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',
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);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
// Service worker - handle push event
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,
dateOfArrival: Date.now()
},
actions: [
{
action: 'open',
title: 'Open',
icon: '/icons/open.png'
},
{
action: 'close',
title: 'Close',
icon: '/icons/close.png'
}
]
};
event.waitUntil(
self.registration.showNotification(data.title, options)
);
});
// Handle notification click
self.addEventListener('notificationclick', (event) => {
event.notification.close();
if (event.action === 'open') {
event.waitUntil(
clients.openWindow(event.notification.data.url)
);
}
});
Server-Side Push (Node.js)#
// server.js
const webpush = require('web-push');
// Set VAPID keys
webpush.setVapidDetails(
'mailto:your-email@example.com',
process.env.PUBLIC_VAPID_KEY,
process.env.PRIVATE_VAPID_KEY
);
// Store subscriptions (use database in production)
const subscriptions = new Map();
// Subscribe endpoint
app.post('/api/push/subscribe', (req, res) => {
const subscription = req.body;
subscriptions.set(subscription.endpoint, subscription);
res.status(201).json({ success: true });
});
// Send push notification
app.post('/api/push/send', async (req, res) => {
const { title, body, url } = req.body;
const payload = JSON.stringify({ title, body, url });
const promises = Array.from(subscriptions.values()).map(subscription => {
return webpush.sendNotification(subscription, payload)
.catch(error => {
if (error.statusCode === 410) {
// Subscription expired, remove it
subscriptions.delete(subscription.endpoint);
}
});
});
await Promise.all(promises);
res.json({ success: true, sent: promises.length });
});
App Installation#
Install Prompt#
// Capture install prompt
let deferredPrompt;
window.addEventListener('beforeinstallprompt', (event) => {
// Prevent default prompt
event.preventDefault();
// Store event for later use
deferredPrompt = event;
// Show custom install button
showInstallButton();
});
function showInstallButton() {
const installButton = document.getElementById('install-button');
installButton.style.display = 'block';
installButton.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;
installButton.style.display = 'none';
});
}
// Track installation
window.addEventListener('appinstalled', (event) => {
console.log('PWA installed successfully');
// Track with analytics
if (window.gtag) {
gtag('event', 'pwa_install', {
event_category: 'engagement',
event_label: 'PWA Installation'
});
}
});
// Detect if running as installed app
function isRunningStandalone() {
return window.matchMedia('(display-mode: standalone)').matches ||
window.navigator.standalone === true;
}
if (isRunningStandalone()) {
console.log('Running as installed PWA');
// Hide install button, show app-specific UI
}
Performance Optimization#
App Shell Architecture#
// Cache app shell immediately
const APP_SHELL = [
'/',
'/index.html',
'/styles/app-shell.css',
'/scripts/app-shell.js',
'/images/logo.svg'
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open('app-shell-v1')
.then(cache => cache.addAll(APP_SHELL))
.then(() => self.skipWaiting())
);
});
// Serve app shell for navigation requests
self.addEventListener('fetch', (event) => {
if (event.request.mode === 'navigate') {
event.respondWith(
caches.match('/index.html')
.then(response => response || fetch(event.request))
);
}
});
Lazy Loading Resources#
// Lazy load images
class LazyLoader {
constructor() {
this.observer = new IntersectionObserver(
this.handleIntersection.bind(this),
{ rootMargin: '50px' }
);
}
observe(elements) {
elements.forEach(el => this.observer.observe(el));
}
handleIntersection(entries) {
entries.forEach(entry => {
if (entry.isIntersecting) {
this.loadImage(entry.target);
this.observer.unobserve(entry.target);
}
});
}
loadImage(img) {
const src = img.dataset.src;
if (!src) return;
img.src = src;
img.classList.add('loaded');
}
}
// Usage
const lazyLoader = new LazyLoader();
const images = document.querySelectorAll('img[data-src]');
lazyLoader.observe(images);
Preloading Critical Resources#
<!-- Preload critical resources -->
<link rel="preload" href="/styles/critical.css" as="style">
<link rel="preload" href="/scripts/app.js" as="script">
<link rel="preload" href="/fonts/main.woff2" as="font" type="font/woff2" crossorigin>
<!-- Prefetch next page -->
<link rel="prefetch" href="/page2.html">
<!-- DNS prefetch for external resources -->
<link rel="dns-prefetch" href="//api.example.com">
<link rel="preconnect" href="//api.example.com">
Testing Your PWA#
Lighthouse Audit#
# Install Lighthouse CLI
npm install -g lighthouse
# Run audit
lighthouse https://your-pwa.com --view
# Generate report
lighthouse https://your-pwa.com --output html --output-path ./report.html
PWA Checklist#
- [ ] HTTPS enabled
- [ ] Responsive design
- [ ] Offline functionality
- [ ] Fast load time (< 3s)
- [ ] Service worker registered
- [ ] Web app manifest
- [ ] Installable
- [ ] App icons (all sizes)
- [ ] Splash screens
- [ ] Theme color
- [ ] Meta tags
- [ ] Structured data
- [ ] Push notifications (optional)
- [ ] Background sync (optional)
Manual Testing#
// Test service worker
navigator.serviceWorker.getRegistrations().then(registrations => {
console.log('Active service workers:', registrations.length);
registrations.forEach(reg => console.log(reg.scope));
});
// Test cache
caches.keys().then(names => {
console.log('Cache names:', names);
names.forEach(name => {
caches.open(name).then(cache => {
cache.keys().then(keys => {
console.log(`${name} has ${keys.length} entries`);
});
});
});
});
// Test offline
window.addEventListener('online', () => {
console.log('Back online');
document.body.classList.remove('offline');
});
window.addEventListener('offline', () => {
console.log('Gone offline');
document.body.classList.add('offline');
});
Production Deployment#
Build Configuration#
// next.config.js (Next.js)
const withPWA = require('next-pwa')({
dest: 'public',
register: true,
skipWaiting: true,
disable: process.env.NODE_ENV === 'development',
runtimeCaching: [
{
urlPattern: /^https:\/\/fonts\.(?:googleapis|gstatic)\.com\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'google-fonts',
expiration: {
maxEntries: 4,
maxAgeSeconds: 365 * 24 * 60 * 60 // 1 year
}
}
},
{
urlPattern: /^https:\/\/api\.example\.com\/.*/i,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
networkTimeoutSeconds: 10,
expiration: {
maxEntries: 50,
maxAgeSeconds: 5 * 60 // 5 minutes
}
}
},
{
urlPattern: /\.(?:jpg|jpeg|png|gif|webp|svg)$/i,
handler: 'CacheFirst',
options: {
cacheName: 'images',
expiration: {
maxEntries: 60,
maxAgeSeconds: 30 * 24 * 60 * 60 // 30 days
}
}
}
]
});
module.exports = withPWA({
// Your Next.js config
});
Deployment Checklist#
- Build optimized bundle
npm run build
npm run export # if using static export
- Test production build locally
npx serve -s out
- Configure CDN caching
- Static assets: 1 year
- Service worker: No cache
- HTML: Short cache with revalidation
- Set up monitoring
- Track install rate
- Monitor offline usage
- Track push notification engagement
- Monitor service worker errors
Frequently Asked Questions#
Q: Do PWAs work on iOS? A: Yes! iOS 11.3+ supports PWAs with some limitations. Push notifications aren't supported on iOS yet, but offline functionality and installation work well.
Q: Can PWAs access device features? A: Yes. PWAs can access camera, geolocation, sensors, Bluetooth, NFC, and more through Web APIs. Some features require HTTPS and user permission.
Q: How do I update my PWA? A: Update your service worker version and assets. The new service worker will install in the background and activate when all tabs are closed or on manual refresh.
Q: What's the difference between PWA and native apps? A: PWAs run in browsers, are instantly accessible, and work across platforms. Native apps have deeper OS integration but require app store approval and separate codebases per platform.
Q: How much storage can PWAs use? A: It varies by browser and device. Chrome allows up to 60% of available disk space. Use the Storage API to check available space and request persistent storage.
Progressive Web Apps represent the future of web development, combining the reach of the web with the capabilities of native apps. Start building your PWA today and deliver exceptional experiences to your users.
Continue Reading
Serverless Edge Computing: The 2026 Revolution in Web Performance
Edge computing is revolutionizing web performance. Learn how to leverage Cloudflare Workers, Vercel Edge Functions, and Deno Deploy for lightning-fast applications.
WebAssembly vs JavaScript: The Performance Revolution in 2026
WebAssembly is reshaping web development. Learn when and how to use WASM for maximum performance gains, with practical examples and real-world case studies.
Next.js Performance Optimization: 10 Essential Techniques
Essential Next.js performance optimization techniques. Learn image optimization, caching, bundle splitting, and how to improve Core Web Vitals.
Browse by Topic
Find stories that matter to you.