Progressive Web Apps promise the best of both worlds: the reach of the web with the experience of native apps. In practice, most PWAs feel like websites pretending to be apps. But when done right, PWAs can be indistinguishable from native—and dramatically easier to maintain.
We recently shipped a PWA that works fully offline, installs with one tap, and gets regular usage from 50K+ users who often don't realize it's a web app. Here's how we built it.
The Core Architecture Decisions
1. Offline-First by Default
Most PWAs treat offline mode as an edge case. We designed for offline-first from day one. Every feature had to work without a network connection:
- Service Worker intercepts all network requests
- IndexedDB stores all user data locally
- Background sync queues actions when offline
- Network is treated as a progressive enhancement, not a requirement
This architecture shift changed everything. Instead of "what happens if the network fails?", we asked "what value can we provide with zero network?"
2. Smart Service Worker Caching
We use a hybrid caching strategy based on resource type:
// service-worker.js
const CACHE_VERSION = 'v1.2.0';
const STATIC_CACHE = `static-${CACHE_VERSION}`;
const DYNAMIC_CACHE = `dynamic-${CACHE_VERSION}`;
const API_CACHE = `api-${CACHE_VERSION}`;
// Install: Cache essential static assets
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(STATIC_CACHE).then((cache) => {
return cache.addAll([
'/',
'/styles.css',
'/app.js',
'/offline.html',
'/manifest.json'
]);
})
);
});
// Fetch: Different strategies for different resources
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// API requests: Network-first, fall back to cache
if (url.pathname.startsWith('/api/')) {
event.respondWith(networkFirstStrategy(request));
}
// Static assets: Cache-first
else if (request.destination === 'style' ||
request.destination === 'script' ||
request.destination === 'image') {
event.respondWith(cacheFirstStrategy(request));
}
// HTML: Stale-while-revalidate
else {
event.respondWith(staleWhileRevalidateStrategy(request));
}
});
async function networkFirstStrategy(request) {
try {
const networkResponse = await fetch(request);
const cache = await caches.open(API_CACHE);
cache.put(request, networkResponse.clone());
return networkResponse;
} catch (error) {
const cachedResponse = await caches.match(request);
return cachedResponse || new Response(
JSON.stringify({ error: 'Offline', cached: false }),
{ status: 503, headers: { 'Content-Type': 'application/json' } }
);
}
}
async function cacheFirstStrategy(request) {
const cachedResponse = await caches.match(request);
if (cachedResponse) return cachedResponse;
try {
const networkResponse = await fetch(request);
const cache = await caches.open(DYNAMIC_CACHE);
cache.put(request, networkResponse.clone());
return networkResponse;
} catch (error) {
// Return offline fallback for images
if (request.destination === 'image') {
return caches.match('/offline-image.svg');
}
}
}
This strategy means users get instant responses from cache while we update in the background. The app feels native-fast because most requests never hit the network.
Handling Data Synchronization
Background Sync for Offline Actions
When users perform actions offline, we queue them using the Background Sync API:
// Queue an action when offline
async function saveItem(item) {
// Save to IndexedDB immediately
await db.items.put(item);
// Queue sync when back online
if ('serviceWorker' in navigator && 'sync' in registration) {
try {
await registration.sync.register('sync-items');
} catch (error) {
// Fallback: immediate retry
await syncItems();
}
}
}
// Service Worker: Handle background sync
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-items') {
event.waitUntil(syncPendingItems());
}
});
async function syncPendingItems() {
const pendingItems = await db.items
.where('syncStatus')
.equals('pending')
.toArray();
for (const item of pendingItems) {
try {
await fetch('/api/items', {
method: 'POST',
body: JSON.stringify(item),
headers: { 'Content-Type': 'application/json' }
});
// Mark as synced
await db.items.update(item.id, { syncStatus: 'synced' });
} catch (error) {
// Will retry on next sync event
console.error('Sync failed:', error);
}
}
}
Conflict Resolution
When the same data changes both locally and on the server, we use timestamp-based last-write-wins with user notification:
async function mergeChanges(localItem, serverItem) {
// Simple last-write-wins
if (localItem.updatedAt > serverItem.updatedAt) {
// Local is newer, push to server
await syncToServer(localItem);
return localItem;
} else if (serverItem.updatedAt > localItem.updatedAt) {
// Server is newer, update local
await db.items.put(serverItem);
return serverItem;
}
// Same timestamp but different content = conflict
if (JSON.stringify(localItem) !== JSON.stringify(serverItem)) {
// Notify user and let them choose
await showConflictResolutionUI(localItem, serverItem);
}
return localItem;
}
Making Installation Seamless
The install experience makes or breaks PWA adoption. We improved our install rate from 8% to 32% with these changes:
1. Smart Install Prompt Timing
let deferredPrompt;
window.addEventListener('beforeinstallprompt', (e) => {
// Prevent automatic prompt
e.preventDefault();
deferredPrompt = e;
// Show install button after user has used app for 5 minutes
setTimeout(() => {
if (hasEngagedWithApp()) {
showInstallButton();
}
}, 300000);
});
function showInstallButton() {
const installBtn = document.getElementById('install-btn');
installBtn.style.display = 'block';
installBtn.addEventListener('click', async () => {
if (deferredPrompt) {
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
if (outcome === 'accepted') {
trackEvent('pwa_installed');
}
deferredPrompt = null;
}
});
}
2. Manifest That Works Everywhere
{
"name": "Your App Name",
"short_name": "App",
"start_url": "/",
"display": "standalone",
"background_color": "#0a0a0f",
"theme_color": "#6366f1",
"orientation": "portrait",
"icons": [
{
"src": "/icons/icon-72x72.png",
"sizes": "72x72",
"type": "image/png"
},
{
"src": "/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
The `maskable` icon is crucial for Android—without it, your icon looks cropped or weird on many devices.
Performance Optimization
PWAs need to load fast to feel native. Our optimization checklist:
- Code splitting: Load only what's needed for the current view
- Route-based chunking: Each route is its own bundle
- Lazy load images: IntersectionObserver for below-fold images
- Preload critical resources: Use `` for fonts and critical CSS
- Compress everything: Brotli compression on all text assets
Results:
- First Contentful Paint: <1.2s (3G connection)
- Time to Interactive: <3.5s
- Lighthouse PWA score: 100/100
The Hard Parts
iOS Limitations
iOS support for PWAs is... complicated. Major limitations we had to work around:
- No Background Sync API (we poll when app is opened)
- No push notifications (we use a hybrid approach with a companion native app for users who want notifications)
- 50MB cache limit (we aggressively clean old caches)
- Data can be cleared if device storage is low (we warn users)
Browser Inconsistencies
What works in Chrome might not work in Safari or Firefox. We test on:
- Chrome Android (best support)
- Safari iOS (most limitations)
- Firefox (good support, some quirks)
- Samsung Internet (surprisingly good)
Key Learnings
- Design for offline-first: Network should be an enhancement, not a requirement
- Use appropriate caching strategies: Different resources need different approaches
- Test on real devices: Service Worker behavior differs significantly across browsers
- Handle sync failures gracefully: Users will be offline more than you think
- Time install prompts carefully: Show it after users are engaged, not immediately
- Accept iOS limitations: Build for the best experience on Chrome/Android, then progressively enhance for iOS
- Monitor cache sizes: Aggressive caching can consume storage quickly
PWAs aren't a silver bullet—they can't replace native apps for everything. But for content-focused apps, utilities, and tools that don't need deep platform integration, PWAs offer a compelling alternative: one codebase, near-instant updates, and no app store gatekeepers.
When done right, PWAs feel native. And that's the point.