Progressive Web Apps Complete Guide 2026: Service Workers, Offline-First, and Beyond
The definitive PWA guide for 2026. Learn service workers, Web App Manifest, offline-first strategies, push notifications, installability, and how PWAs compare to native apps—with real code examples.
Progressive Web Apps aren’t new. Google coined the term in 2015. But in 2026, the gap between a well-built PWA and a native app has narrowed to the point where “should we build an app?” is often the wrong question. The right question is: “have we made our web app as capable as it can be?”
This guide covers everything you need to build a production-grade PWA in 2026: service workers from scratch, the Web App Manifest, offline-first data strategies, push notifications, and an honest comparison of where PWAs still fall short versus native.
What Makes a Web App “Progressive”
A Progressive Web App is a web application that uses modern browser APIs to deliver app-like capabilities:
- Installable: Users can add it to their home screen or desktop
- Offline-capable: Works without a network connection (or with a degraded but functional experience)
- Fast: Loads quickly even on slow connections
- Push-enabled: Can receive notifications even when the browser is closed
- Secure: Served over HTTPS (required for service workers)
These aren’t binary—your app can adopt any of these features incrementally. That’s the “progressive” part. Start with what makes sense for your users, add more as the value becomes clear.
The Service Worker: The Heart of PWAs
A service worker is a JavaScript file that runs in a separate thread from your main application. It acts as a programmable proxy between your app and the network, intercepting requests and deciding how to respond.
Registering a Service Worker
// main.js (your app entry point)
if ('serviceWorker' in navigator) {
window.addEventListener('load', async () => {
try {
const registration = await navigator.serviceWorker.register('/sw.js', {
scope: '/'
});
console.log('Service worker registered:', registration.scope);
} catch (error) {
console.error('Service worker registration failed:', error);
}
});
}
The scope controls which URLs the service worker can intercept. A service worker at /sw.js with scope / controls the entire origin. One at /app/sw.js with scope /app/ only controls URLs under /app/.
The Service Worker Lifecycle
Understanding the lifecycle prevents the most common PWA bugs:
Install → Activate → Fetch/Message/Push
Install: Triggered when the browser first encounters a new (or updated) service worker. Use this to pre-cache critical assets.
Activate: Fires after install, once no old service worker is controlling any clients. Use this to clean up old caches.
Fetch: Fires for every network request made by pages in scope. This is where you implement caching strategies.
// sw.js
const CACHE_NAME = 'devplaybook-v3';
const PRECACHE_URLS = [
'/',
'/offline.html',
'/styles/main.css',
'/scripts/app.js',
];
// Install: pre-cache critical assets
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(PRECACHE_URLS);
})
);
// Activate immediately, don't wait for old SW to go away
self.skipWaiting();
});
// Activate: 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))
);
})
);
// Take control of all clients immediately
self.clients.claim();
});
Caching Strategies
The right caching strategy depends on the resource type. Here are the five patterns you’ll use in production:
1. Cache First (for static assets)
Check the cache first. Only fetch from network if not found. Best for versioned assets (CSS, JS with hashes in filenames) that rarely change.
async function cacheFirst(request) {
const cached = await caches.match(request);
if (cached) return cached;
const response = await fetch(request);
const cache = await caches.open(CACHE_NAME);
cache.put(request, response.clone());
return response;
}
2. Network First (for API data)
Try the network. Fall back to cache on failure. Best for dynamic data where freshness matters.
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) {
const cached = await caches.match(request);
if (cached) return cached;
// Return offline fallback
return caches.match('/offline.html');
}
}
3. Stale-While-Revalidate (for content that updates regularly)
Return from cache immediately (fast), then fetch from network in the background to update the cache for next time. Best for content that updates regularly but where stale data is acceptable.
async function staleWhileRevalidate(request) {
const cache = await caches.open(CACHE_NAME);
const cached = await cache.match(request);
// Fetch in background regardless
const fetchPromise = fetch(request).then((response) => {
cache.put(request, response.clone());
return response;
});
// Return cache immediately if available, else wait for fetch
return cached || fetchPromise;
}
4. Cache Only (for offline-first assets)
Never hit the network. Only serve from cache. Use for assets you’ve explicitly pre-cached.
5. Network Only (for non-cacheable requests)
Never use the cache. Always go to network. Use for analytics, payment endpoints, and other requests where stale data would be harmful.
Putting It Together: Route-Based Strategy Selection
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// Skip non-GET and cross-origin requests
if (request.method !== 'GET') return;
if (url.origin !== self.location.origin) return;
// API routes: network first
if (url.pathname.startsWith('/api/')) {
event.respondWith(networkFirst(request));
return;
}
// Static assets: cache first
if (url.pathname.match(/\.(js|css|woff2|png|jpg|svg)$/)) {
event.respondWith(cacheFirst(request));
return;
}
// HTML pages: stale-while-revalidate
event.respondWith(staleWhileRevalidate(request));
});
Web App Manifest
The manifest file tells the browser how to present your app when installed. It’s a JSON file linked from your HTML.
<!-- index.html -->
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#2563eb">
{
"name": "DevPlaybook",
"short_name": "DevPlaybook",
"description": "Developer tools and references, always available",
"start_url": "/?source=pwa",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#2563eb",
"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"
}
],
"screenshots": [
{
"src": "/screenshots/desktop.png",
"sizes": "1280x720",
"type": "image/png",
"form_factor": "wide",
"label": "Desktop view"
},
{
"src": "/screenshots/mobile.png",
"sizes": "390x844",
"type": "image/png",
"form_factor": "narrow",
"label": "Mobile view"
}
],
"shortcuts": [
{
"name": "Tools",
"url": "/tools",
"icons": [{ "src": "/icons/tools.png", "sizes": "96x96" }]
}
]
}
Key display values:
standalone: Looks like a native app (no browser UI)minimal-ui: Browser UI with minimal controlsfullscreen: Full screen (no browser chrome at all)browser: Regular browser tab (no PWA feel)
The screenshots array enables richer install prompts on Android and some desktop platforms. The shortcuts array adds quick actions to the app icon’s context menu.
Offline-First Data Strategies
Making your UI available offline is the easy part. Making your data available offline—and syncing changes made while offline—is where real offline-first gets complex.
IndexedDB for Persistent Storage
localStorage is synchronous and limited to strings. For offline data storage, use IndexedDB (or a library like Dexie.js that wraps it).
// Using Dexie.js for readable IndexedDB access
import Dexie from 'dexie';
const db = new Dexie('DevPlaybookDB');
db.version(1).stores({
articles: '++id, slug, title, updatedAt, *tags',
syncQueue: '++id, url, method, body, createdAt'
});
// Save article for offline reading
export async function cacheArticle(article) {
await db.articles.put(article);
}
// Get all cached articles
export async function getCachedArticles() {
return db.articles.orderBy('updatedAt').reverse().toArray();
}
Background Sync for Deferred Actions
Background Sync lets you queue actions (form submissions, likes, data writes) that failed due to network loss, and replay them automatically when connectivity returns.
// In your app: queue the action when offline
async function submitFormWithSync(formData) {
if (!navigator.onLine) {
await db.syncQueue.add({
url: '/api/submissions',
method: 'POST',
body: JSON.stringify(formData),
createdAt: Date.now()
});
const registration = await navigator.serviceWorker.ready;
await registration.sync.register('submit-form');
return { queued: true };
}
return fetch('/api/submissions', {
method: 'POST',
body: JSON.stringify(formData)
});
}
// In sw.js: replay queued actions when online
self.addEventListener('sync', (event) => {
if (event.tag === 'submit-form') {
event.waitUntil(replayQueue());
}
});
async function replayQueue() {
const queued = await db.syncQueue.orderBy('createdAt').toArray();
for (const item of queued) {
try {
await fetch(item.url, {
method: item.method,
body: item.body,
headers: { 'Content-Type': 'application/json' }
});
await db.syncQueue.delete(item.id);
} catch (err) {
// Will retry on next sync event
break;
}
}
}
Push Notifications
Push notifications work even when the browser is closed. The flow:
- User grants permission
- Browser subscribes to a push service (each browser has its own)
- Your server gets the subscription endpoint
- Your server sends push messages to that endpoint
- Service worker receives the message and shows a notification
Requesting Permission and Subscribing
// app.js
async function subscribeToPush() {
const permission = await Notification.requestPermission();
if (permission !== 'granted') return null;
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true, // Required: must show notification for every push
applicationServerKey: urlBase64ToUint8Array(PUBLIC_VAPID_KEY)
});
// Send subscription to your server
await fetch('/api/push-subscriptions', {
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 = atob(base64);
return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0)));
}
Generating VAPID Keys
# Install web-push package
npm install web-push
# Generate VAPID keys
npx web-push generate-vapid-keys
Handling Push in the Service Worker
// sw.js
self.addEventListener('push', (event) => {
const data = event.data?.json() ?? {};
event.waitUntil(
self.registration.showNotification(data.title || 'DevPlaybook', {
body: data.body,
icon: '/icons/icon-192.png',
badge: '/icons/badge-72.png',
data: { url: data.url || '/' },
actions: [
{ action: 'open', title: 'Open' },
{ action: 'dismiss', title: 'Dismiss' }
]
})
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
if (event.action === 'dismiss') return;
const url = event.notification.data?.url || '/';
event.waitUntil(
clients.matchAll({ type: 'window' }).then((windowClients) => {
const client = windowClients.find((c) => c.url === url && 'focus' in c);
if (client) return client.focus();
return clients.openWindow(url);
})
);
});
Sending Push from Your Server
// server.js (Node.js)
import webpush from 'web-push';
webpush.setVapidDetails(
'mailto:[email protected]',
process.env.VAPID_PUBLIC_KEY,
process.env.VAPID_PRIVATE_KEY
);
export async function sendPushNotification(subscription, payload) {
try {
await webpush.sendNotification(subscription, JSON.stringify(payload));
} catch (error) {
if (error.statusCode === 410) {
// Subscription expired — remove it from your database
await removeSubscription(subscription.endpoint);
}
throw error;
}
}
PWA Installability Requirements
To trigger the browser’s install prompt, your app must meet these criteria (Chrome/Edge):
- Served over HTTPS (or localhost for dev)
- Has a valid Web App Manifest with
name,short_name,start_url,display(standalone/fullscreen/minimal-ui), and at least a 192x192 icon - Has a registered service worker with a
fetchhandler - Hasn’t been installed already
You can capture the beforeinstallprompt event to show your own install UI:
let deferredPrompt;
window.addEventListener('beforeinstallprompt', (event) => {
event.preventDefault();
deferredPrompt = event;
showInstallButton();
});
async function installApp() {
if (!deferredPrompt) return;
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
console.log(`Install outcome: ${outcome}`); // 'accepted' or 'dismissed'
deferredPrompt = null;
hideInstallButton();
}
window.addEventListener('appinstalled', () => {
console.log('PWA installed');
// Track installation in analytics
});
Performance Optimization for PWAs
A PWA that installs well but loads slowly defeats the purpose. Key optimizations:
Preloading Critical Resources
<link rel="preload" href="/styles/main.css" as="style">
<link rel="preload" href="/scripts/app.js" as="script">
<link rel="preconnect" href="https://api.example.com">
App Shell Pattern
Separate your “shell” (navigation, layout, chrome) from your content. Pre-cache the shell; load content dynamically.
// Shell routes get cache-first treatment
const SHELL_URLS = ['/', '/tools', '/articles', '/offline.html'];
// Content gets network-first with cache fallback
self.addEventListener('fetch', (event) => {
if (SHELL_URLS.includes(new URL(event.request.url).pathname)) {
event.respondWith(cacheFirst(event.request));
} else {
event.respondWith(networkFirst(event.request));
}
});
Workbox for Production Use
Writing service worker caching logic by hand is error-prone. Workbox (by Google) handles edge cases you’ll miss:
// sw.js with Workbox
import { registerRoute } from 'workbox-routing';
import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
import { precacheAndRoute } from 'workbox-precaching';
// Precache assets injected by build tool
precacheAndRoute(self.__WB_MANIFEST);
// Images: cache first, expire after 30 days
registerRoute(
({ request }) => request.destination === 'image',
new CacheFirst({
cacheName: 'images',
plugins: [new ExpirationPlugin({ maxAgeSeconds: 30 * 24 * 60 * 60 })]
})
);
// API: network first, fall back to cache
registerRoute(
({ url }) => url.pathname.startsWith('/api/'),
new NetworkFirst({ cacheName: 'api-data' })
);
// Pages: stale-while-revalidate
registerRoute(
({ request }) => request.mode === 'navigate',
new StaleWhileRevalidate({ cacheName: 'pages' })
);
PWA vs Native Apps: An Honest Comparison in 2026
| Capability | PWA | Native (iOS/Android) |
|---|---|---|
| Push notifications | ✅ All platforms (iOS 16.4+) | ✅ |
| Offline support | ✅ Full control | ✅ |
| Camera / microphone | ✅ | ✅ |
| Bluetooth / NFC | ⚠️ Chrome only | ✅ |
| Background sync | ✅ | ✅ |
| App store distribution | ⚠️ via TWA (Android), limited iOS | ✅ |
| Deep system integration | ❌ | ✅ |
| Biometric auth | ✅ WebAuthn | ✅ |
| In-app purchases | ❌ | ✅ |
| AR / VR | ⚠️ WebXR (limited) | ✅ |
| Install prompt (iOS) | ⚠️ Manual only | ✅ |
| Development cost | ✅ One codebase | ❌ Two codebases |
Choose PWA when: Your app is content-heavy, cross-platform reach matters more than deep OS integration, you want faster iteration cycles, or budget constrains you to one codebase.
Choose native when: You need app store discovery, in-app purchases, advanced hardware access (ARKit, Bluetooth, background location), or your users expect platform-specific UX conventions.
The hybrid play: Build a PWA first, then wrap it as a Trusted Web Activity (TWA) for Google Play distribution. You get the Play Store listing without maintaining a separate codebase.
Testing Your PWA
Lighthouse Audit
Chrome DevTools includes Lighthouse, which runs a PWA audit:
- Open DevTools → Lighthouse tab
- Check “Progressive Web App” category
- Run audit
- Look for the PWA installability checklist and best practices score
Testing Offline Behavior
# Simulate offline in DevTools:
# Network tab → Throttling dropdown → Offline
# Or test with Workbox's dev server
npx workbox wizard
Testing Push Notifications
Chrome DevTools → Application → Service Workers → Push button sends a test push event to your service worker without needing a server.
Common PWA Pitfalls
1. Cache poisoning: Never cache error responses. Always check response.ok before caching.
const response = await fetch(request);
if (response.ok) {
cache.put(request, response.clone());
}
return response;
2. skipWaiting() timing: Calling skipWaiting() during install activates the new service worker immediately—while old clients are still running with the old version. For most apps this is fine. For apps with strict cache consistency requirements, wait for all clients to close.
3. opaque responses: Cross-origin responses without CORS headers are “opaque”—you can cache them but you can’t inspect or check their status. They always appear as status 0.
4. iOS limitations: As of 2026, iOS Safari supports service workers, but the install experience requires users to use “Add to Home Screen” from the Share menu—there’s no automatic install prompt. Notification permission on iOS requires a PWA to already be installed to the home screen.
5. Cache size limits: Browsers impose per-origin storage limits (varies by browser and available disk space). Use the Storage Manager API to check:
const estimate = await navigator.storage.estimate();
console.log(`Using ${estimate.usage} of ${estimate.quota} bytes`);
Quick Start Checklist
Use this to audit your own app:
- Served over HTTPS
-
manifest.jsonlinked in HTML with required fields - Icons at 192x192 and 512x512 (maskable variants)
- Service worker registered with fetch handler
- Offline fallback page at
/offline.html - Critical assets pre-cached on install
-
beforeinstallpromptcaptured for custom install UI - Push subscription flow implemented (if applicable)
- Cache cleanup in activate event
- Lighthouse PWA audit passes
- Tested with DevTools Network throttling set to Offline
Conclusion
Progressive Web Apps in 2026 are a genuine alternative to native apps for a wide class of products. The core APIs—service workers, Web App Manifest, Background Sync, Push—are stable, well-supported, and well-documented. The remaining gaps (iOS install flow, in-app purchases, deep hardware access) are real, but narrower than they were two years ago.
The best argument for PWAs isn’t that they’re “as good as native.” It’s that they’re already what most users need, available without an app store, updatable instantly, and buildable with the skills your team already has.
Start with a service worker and a manifest. Ship the offline fallback. Add push notifications if your use case benefits from them. Audit with Lighthouse. The rest follows naturally.
Looking for related tools? Check out Lighthouse CI for automated PWA auditing, Workbox for production-ready service worker patterns, and PWA Builder for packaging PWAs for app store submission.
50 CLI Commands That Save Hours Every Week
The exact Git shortcuts, file search tricks, Docker commands, and productivity hacks that senior developers use daily. Compiled from 5+ years of shipping production code.
- Git mastery (10 cmds)
- File & search wizardry
- Docker & DevOps tricks
- Process & network tools
- Productivity boosters
- macOS & Linux install guide
No spam. 1 email/week max. Unsubscribe anytime.
Found this guide useful? Check out our free developer tools.
Affiliate disclosure: Some links below are affiliate links — we may earn a small commission at no extra cost to you. Learn more.
Recommended Tools & Resources
DigitalOcean
$200 credit for new users. Simple, affordable cloud hosting for developers.
GitHub Student Pack
Free access to 100+ developer tools. Perfect for students and new devs.
Vercel
Deploy frontend apps instantly. Free tier is generous for side projects.
DevPlaybook Products
Boilerplates, scripts & AI toolkits to 10x your dev workflow.