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
Small SaaS Launch Architecture
A single application with private file storage, tenant-scoped records, and idempotent billing events. Start small; scale from measured demand.
5 componentsFeedback to Roadmap Architecture
Preserve original evidence separately from model suggestions and require review before publishing roadmap changes.
5 componentsWebsite Monitoring Architecture
Isolate browsing, store dated evidence, and separate scheduled checks from approved report delivery.
5 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