Progressive Web App Architecture: Service Workers, Caching & Offline Support
Progressive Web App Architecture#
A Progressive Web App (PWA) delivers native-app capabilities through the browser — offline access, push notifications, home-screen installation — while keeping the reach and simplicity of the web.
What Makes a PWA#
PWA = HTTPS + Service Worker + Web App Manifest
┌──────────────────────────────────┐
│ Browser Shell │
│ ┌────────────────────────────┐ │
│ │ Your Web App │ │
│ │ (HTML, CSS, JS, Assets) │ │
│ └────────────┬───────────────┘ │
│ │ │
│ ┌────────────▼───────────────┐ │
│ │ Service Worker │ │
│ │ (proxy between app │ │
│ │ and network) │ │
│ └────────────┬───────────────┘ │
│ │ │
│ ┌────────────▼───────────────┐ │
│ │ Cache Storage │ │
│ │ (offline-ready assets) │ │
│ └────────────────────────────┘ │
└──────────────────────────────────┘
Core Requirements#
- HTTPS — service workers only run on secure origins
- Service Worker — a JavaScript proxy that intercepts network requests
- Web App Manifest — JSON file describing name, icons, theme, display mode
Service Workers#
A service worker is an event-driven script that runs in a separate thread from your page.
Lifecycle#
1. Register → browser downloads the SW file
2. Install → SW caches critical assets (precaching)
3. Activate → old SW replaced, new one takes control
4. Idle → waits for fetch/push/sync events
5. Terminated → browser kills idle SW to save memory
6. Update → byte-diff triggers new install cycle
Registration#
// main.js
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js', {
scope: '/'
});
}
Basic Fetch Interception#
// sw.js
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then((cached) => {
return cached || fetch(event.request);
})
);
});
Cache Strategies#
Choosing the right strategy per resource type is the most important PWA design decision.
Cache-First (Cache Falling Back to Network)#
Best for static assets that rarely change — fonts, images, CSS/JS bundles with hashed filenames.
Request → Cache hit?
YES → return cached response
NO → fetch from network → cache response → return
Network-First (Network Falling Back to Cache)#
Best for dynamic content that must be fresh — API responses, news feeds, dashboards.
Request → Fetch from network
SUCCESS → cache response → return
FAILURE → return cached response (stale but available)
Stale-While-Revalidate#
Best for semi-dynamic content — user avatars, product listings, blog posts.
Request → Return cached response immediately (fast)
→ Simultaneously fetch from network
→ Update cache with fresh response (next load is fresh)
Cache-Only#
Best for precached assets during offline-only mode.
Request → Return from cache (never touches network)
Network-Only#
For requests that must never be cached — analytics pings, payment endpoints.
Request → Always fetch from network (bypass cache)
Strategy Cheat Sheet#
| Resource | Strategy | Cache Expiration |
|---|---|---|
| App shell (HTML) | Stale-while-revalidate | 1 hour |
| CSS/JS bundles | Cache-first | Until hash changes |
| Fonts | Cache-first | 1 year |
| API data | Network-first | 5 minutes |
| Images | Cache-first | 30 days |
| User-specific data | Network-first | No cache or short TTL |
Offline Support#
App Shell Pattern#
Cache the minimal HTML, CSS, and JS needed to render the UI skeleton. Content loads from cache or network.
Online: App Shell + Live Data
Offline: App Shell + Cached Data + "You're offline" banner
Background Sync#
Queue failed requests and replay them when connectivity returns:
// Register a sync event
self.addEventListener('sync', (event) => {
if (event.tag === 'send-messages') {
event.waitUntil(replayQueuedMessages());
}
});
IndexedDB for Structured Data#
Cache Storage works for request/response pairs. For structured data, use IndexedDB:
Cache Storage → HTML, CSS, JS, images (HTTP responses)
IndexedDB → JSON objects, user data, app state
Push Notifications#
Architecture#
┌──────────┐ subscribe ┌──────────────┐
│ Browser │ ─────────────→ │ Push Service │
│ (client) │ │ (FCM / APNs) │
└──────────┘ └───────┬───────┘
│
┌──────────┐ send payload ┌───────▼───────┐
│ Your │ ─────────────→ │ Push Service │
│ Server │ │ delivers to │
└──────────┘ │ client SW │
└───────────────┘
Requesting Permission#
const permission = await Notification.requestPermission();
if (permission === 'granted') {
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: VAPID_PUBLIC_KEY
});
// Send subscription to your server
await sendToServer(subscription);
}
Handling Push Events#
// sw.js
self.addEventListener('push', (event) => {
const data = event.data.json();
event.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
icon: '/icon-192.png',
badge: '/badge-72.png',
data: { url: data.url }
})
);
});
Installability#
Web App Manifest#
{
"name": "My Progressive App",
"short_name": "MyApp",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#1a1a2e",
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }
]
}
Install Criteria (Chromium)#
- Served over HTTPS
- Has a registered service worker with a fetch handler
- Has a valid web app manifest with required fields
- Meets engagement heuristic (user interacted with the app)
Performance & Core Web Vitals#
PWAs must be fast. Google uses Core Web Vitals as ranking signals:
| Metric | What It Measures | Good Target |
|---|---|---|
| LCP (Largest Contentful Paint) | Loading speed | Under 2.5s |
| INP (Interaction to Next Paint) | Responsiveness | Under 200ms |
| CLS (Cumulative Layout Shift) | Visual stability | Under 0.1 |
PWA Performance Tips#
- Precache the app shell so first paint is instant on repeat visits
- Lazy-load images and non-critical JS
- Use stale-while-revalidate for content that can be slightly stale
- Compress assets with Brotli or gzip
- Serve images in WebP/AVIF with fallbacks
- Code-split routes so each page loads only what it needs
Workbox#
Workbox is Google's library for building production service workers without writing low-level cache logic.
Precaching#
import { precacheAndRoute } from 'workbox-precaching';
// Automatically precache build assets
precacheAndRoute(self.__WB_MANIFEST);
Runtime Caching with Strategies#
import { registerRoute } from 'workbox-routing';
import { StaleWhileRevalidate, CacheFirst, NetworkFirst } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
// HTML pages — network-first
registerRoute(
({ request }) => request.mode === 'navigate',
new NetworkFirst({ cacheName: 'pages' })
);
// Images — cache-first with expiration
registerRoute(
({ request }) => request.destination === 'image',
new CacheFirst({
cacheName: 'images',
plugins: [
new ExpirationPlugin({ maxEntries: 100, maxAgeSeconds: 30 * 24 * 60 * 60 })
]
})
);
// API calls — stale-while-revalidate
registerRoute(
({ url }) => url.pathname.startsWith('/api/'),
new StaleWhileRevalidate({ cacheName: 'api-cache' })
);
Workbox Build Integration#
// workbox-config.js
module.exports = {
globDirectory: 'dist/',
globPatterns: ['**/*.{html,js,css,png,svg,woff2}'],
swDest: 'dist/sw.js',
runtimeCaching: [
{
urlPattern: /\/api\//,
handler: 'NetworkFirst',
options: { cacheName: 'api-cache' }
}
]
};
Common Pitfalls#
- Caching too aggressively — stale HTML that never updates is worse than no cache
- No cache versioning — always version your precache manifest so updates propagate
- Ignoring the update flow — prompt users when a new SW is available
- Push notification spam — ask for permission in context, not on first visit
- Forgetting HTTPS — service workers silently fail on HTTP (except localhost)
- Not testing offline — use Chrome DevTools Application panel to simulate offline
PWA Checklist#
[ ] HTTPS enabled
[ ] Service worker registered with fetch handler
[ ] Web app manifest with icons and display mode
[ ] Precache app shell and critical assets
[ ] Cache strategy per resource type
[ ] Offline fallback page
[ ] Push notification support (if needed)
[ ] Core Web Vitals passing (LCP, INP, CLS)
[ ] Lighthouse PWA audit score above 90
Explore the full architecture series on Codelit.io. This is article #347 in our growing library of software architecture and system design guides.
Try it on Codelit
Chaos Mode
Simulate node failures and watch cascading impact across your architecture
AI Architecture Review
Get an AI audit covering security gaps, bottlenecks, and scaling risks
Related articles
Try these templates
Scalable SaaS Application
Modern SaaS with microservices, event-driven processing, and multi-tenant architecture.
10 componentsNetflix Video Streaming Architecture
Global video streaming platform with adaptive bitrate, CDN distribution, and recommendation engine.
10 componentsURL Shortener Service
Scalable URL shortening with analytics, custom aliases, and expiration.
7 componentsBuild this architecture
Generate an interactive Progressive Web App Architecture in seconds.
Try it in Codelit →
Comments