Progressive Web App Architecture: Service Workers, Caching & Offline Support
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 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 componentsContinue learning
Go deeper on system design
As an Amazon Associate I earn from qualifying purchases. Codelit may receive a commission at no extra cost to you.
Building Microservices
Sam Newman · 2021
600 pages on splitting a monolith: service boundaries, data decomposition, migration order.
A Philosophy of Software Design
John Ousterhout · 2021
Short book on module depth and complexity, with before/after code showing why an interface is too wide.
Redis in Action
Josiah L. Carlson · 2013
Cache, lock, queue and rate-limit patterns in Redis written out as complete code, not snippets.
3.8 (27)Designing Data-Intensive Applications
Martin Kleppmann, Chris Riccomini · 2026
Traces replication, consistency and consensus end to end, with the failure modes behind each choice.
3.6 (196)#1 in Data Mining