Loading content...
Progressive Web Apps (PWAs) represent the web platform's answer to native app capabilities. They occupy a fascinating middle ground on the thin-thick client spectrum—enabling thick client patterns (offline operation, local storage, background sync) using web technologies that deploy via URL without app store barriers.
PWAs emerged from the recognition that modern web browsers are powerful application runtimes, not just document viewers. Service Workers, IndexedDB, Web Push, and the Cache API combine to enable experiences previously exclusive to native apps—if you know how to use them.
Understanding PWAs is essential for system designers because they represent a specific architectural choice with distinct trade-offs. They're not simply 'web apps that work offline'—they're a philosophy about how applications should be built, distributed, and updated.
By the end of this page, you will understand PWA architecture comprehensively: the core technologies that enable them, the patterns for building effective PWAs, platform-specific considerations, when PWAs excel versus alternatives, and practical implementation guidance. You'll be equipped to make informed decisions about whether PWA architecture fits your requirements.
The term 'Progressive Web App' was coined by Google engineers Alex Russell and Frances Berriman in 2015. But what actually makes an app a PWA?
Core PWA Characteristics:
PWAs are defined by capabilities and behaviors, not specific technologies:
Progressive Web App: A web application that uses modern web APIs and progressive enhancement to deliver app-like experiences across all devices, with the reliability, speed, and engagement of native applications.
The key characteristics are:
Technical Requirements:
To be recognized as a PWA by browsers (and get install prompts), you need:
| Requirement | Purpose |
|---|---|
| HTTPS | Security for service workers and user trust |
| Web App Manifest | Describes the app (name, icons, theme, display mode) |
| Service Worker | Enables offline, caching, background functionality |
| Responsive Design | Works across viewport sizes |
| Fast Performance | Meets Core Web Vitals thresholds |
Browsers check these requirements before offering 'Add to Home Screen' or 'Install App' prompts.
The 'Progressive' in PWA isn't just a label—it's a philosophy. PWAs should work without JavaScript (basic HTML), work better with JavaScript (enhanced interactivity), work offline with Service Workers, and be installable with comprehensive PWA support. Each layer enhances the experience without breaking lower layers.
PWAs are built on a foundation of specific web platform APIs. Understanding these technologies is essential for effective PWA architecture.
Service Workers: The PWA Engine
Service Workers are the core technology enabling PWA capabilities. They're JavaScript files that run separately from the main browser thread, intercepting network requests and enabling offline functionality.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485
// Service Worker: The PWA Engine// sw.ts - Runs in a separate thread, intercepts all network requests /// <reference lib="webworker" />declare const self: ServiceWorkerGlobalScope; // LIFECYCLE: Install → Activate → Fetch // 1. INSTALL: First-time setup, cache critical assetsself.addEventListener('install', (event) => { event.waitUntil( caches.open('app-shell-v1') .then(cache => cache.addAll([ '/', '/index.html', '/app.js', '/app.css', '/icons/icon-192.png', '/offline.html', // Fallback for offline ])) .then(() => self.skipWaiting()) // Activate immediately );}); // 2. ACTIVATE: Cleanup old caches, take controlself.addEventListener('activate', (event) => { event.waitUntil( caches.keys().then(keys => Promise.all( keys.filter(key => key !== 'app-shell-v1') .map(key => caches.delete(key)) ) ).then(() => self.clients.claim()) // Control all open tabs );}); // 3. FETCH: Intercept every network requestself.addEventListener('fetch', (event) => { const url = new URL(event.request.url); // Strategy selection based on request type if (url.pathname.startsWith('/api/')) { // API: Network-first, cache fallback event.respondWith(networkFirst(event.request)); } else if (url.pathname.match(/\.(png|jpg|svg|woff2)$/)) { // Assets: Cache-first event.respondWith(cacheFirst(event.request)); } else { // HTML/App: Stale-while-revalidate event.respondWith(staleWhileRevalidate(event.request)); }}); // Caching strategiesasync function networkFirst(request: Request): Promise<Response> { try { const response = await fetch(request); const cache = await caches.open('api-cache'); cache.put(request, response.clone()); return response; } catch (error) { const cached = await caches.match(request); return cached || new Response(JSON.stringify({ offline: true }), { status: 503, headers: { 'Content-Type': 'application/json' } }); }} async function cacheFirst(request: Request): Promise<Response> { const cached = await caches.match(request); return cached || fetch(request);} async function staleWhileRevalidate(request: Request): Promise<Response> { const cache = await caches.open('pages'); const cached = await cache.match(request); const fetchPromise = fetch(request).then(response => { cache.put(request, response.clone()); return response; }); return cached || fetchPromise;}Web App Manifest:
The manifest is a JSON file that tells the browser how to handle your PWA when installed:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647
{ "name": "My Progressive Web App", "short_name": "MyPWA", "description": "An example PWA demonstrating core capabilities", "start_url": "/", "display": "standalone", "orientation": "portrait", "background_color": "#1a1a2e", "theme_color": "#16213e", "icons": [ { "src": "/icons/icon-72.png", "sizes": "72x72", "type": "image/png" }, { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" }, { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" } ], "shortcuts": [ { "name": "New Document", "short_name": "New", "description": "Create a new document", "url": "/new", "icons": [{ "src": "/icons/new-doc.png", "sizes": "96x96" }] } ], "share_target": { "action": "/share-handler", "method": "POST", "enctype": "multipart/form-data", "params": { "title": "title", "text": "text", "url": "url" } }}Additional PWA APIs:
| API | Purpose | Browser Support |
|---|---|---|
| Cache API | Store request/response pairs for offline | Excellent |
| IndexedDB | Client-side database for structured data | Excellent |
| Background Sync | Sync data when connectivity returns | Chrome/Edge only |
| Periodic Background Sync | Regular background updates | Chrome/Edge only |
| Web Push | Server-initiated notifications | Good (iOS 16.4+) |
| Badging API | App icon badges/notifications count | Chrome/Edge |
| File System Access | Read/write local files | Chrome/Edge only |
| Share Target | Receive shared content | Chrome/Edge |
Chrome/Edge lead in PWA support. Firefox has basic PWA support but lacks advanced features. Safari (iOS/macOS) has the most limitations—no Background Sync, limited Push Notifications, aggressive storage eviction. Always verify API support on your target platforms.
Choosing the right caching strategy for different resources is critical for PWA performance and reliability. Each strategy represents a different balance between freshness and availability.
Strategy 1: Cache First (Cache Falling Back to Network)
Best for: Static assets (images, fonts, CSS, JS that rarely changes)
Request → Cache hit? Return cached. Cache miss? Fetch, cache, return.
Strategy 2: Network First (Network Falling Back to Cache)
Best for: API calls, data that must be fresh when possible
Request → Try network. Success? Return.and cache. Failure? Return cached.
Strategy 3: Stale-While-Revalidate
Best for: Content that should feel instant but stay fresh (articles, product listings)
Request → Return cached immediately. Also fetch fresh. Update cache.
Strategy 4: Network Only
Best for: Non-GET requests, real-time data, no offline support
Request → Fetch from network. No caching.
Strategy 5: Cache Only
Best for: Pre-cached assets during install, guaranteed offline availability
Request → Return from cache. No network.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980
// Complete caching strategy implementations // Using Workbox (Google's library for service workers)import { registerRoute } from 'workbox-routing';import { CacheFirst, NetworkFirst, StaleWhileRevalidate, NetworkOnly } from 'workbox-strategies';import { CacheableResponsePlugin } from 'workbox-cacheable-response';import { ExpirationPlugin } from 'workbox-expiration'; // 1. CACHE FIRST: Static assetsregisterRoute( ({ request }) => request.destination === 'image' || request.destination === 'font' || request.destination === 'style', new CacheFirst({ cacheName: 'static-assets', plugins: [ new CacheableResponsePlugin({ statuses: [0, 200] }), new ExpirationPlugin({ maxEntries: 100, maxAgeSeconds: 60 * 60 * 24 * 30, // 30 days }), ], })); // 2. NETWORK FIRST: API data (must be fresh when possible)registerRoute( ({ url }) => url.pathname.startsWith('/api/'), new NetworkFirst({ cacheName: 'api-cache', networkTimeoutSeconds: 5, // Fallback to cache after 5s plugins: [ new CacheableResponsePlugin({ statuses: [0, 200] }), new ExpirationPlugin({ maxEntries: 50, maxAgeSeconds: 60 * 60 * 24, // 1 day }), ], })); // 3. STALE-WHILE-REVALIDATE: HTML pagesregisterRoute( ({ request }) => request.destination === 'document', new StaleWhileRevalidate({ cacheName: 'pages', plugins: [ new CacheableResponsePlugin({ statuses: [0, 200] }), ], })); // 4. CUSTOM: Offline fallback for navigationregisterRoute( ({ request }) => request.mode === 'navigate', async ({ event }) => { try { // Try to get the response from the network return await new NetworkFirst({ cacheName: 'pages' }).handle({ event }); } catch (error) { // If we're offline, return the offline page return caches.match('/offline.html'); } }); // 5. PRECACHING: Critical assets during installimport { precacheAndRoute } from 'workbox-precaching'; precacheAndRoute([ { url: '/', revision: '1.0.0' }, { url: '/app.js', revision: '1.0.0' }, { url: '/app.css', revision: '1.0.0' }, { url: '/offline.html', revision: '1.0.0' },]);Writing Service Workers from scratch is error-prone. Google's Workbox library provides battle-tested implementations of caching strategies, precaching, background sync, and more. Use it. It integrates with most build tools (webpack, Vite, etc.) and handles edge cases you won't think of.
True offline capability requires more than caching assets—you need local data storage and synchronization with the server.
IndexedDB for Local Data:
IndexedDB is the browser's local database API. It's asynchronous, transactional, and can store significant data:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081
// IndexedDB with Dexie for PWA local storageimport Dexie, { Table } from 'dexie'; interface Task { id?: number; // Auto-increment for local serverId?: string; // Server ID after sync title: string; completed: boolean; createdAt: Date; updatedAt: Date; syncStatus: 'synced' | 'pending' | 'conflict';} interface SyncQueue { id?: number; operation: 'create' | 'update' | 'delete'; entityType: string; entityId: number; payload: any; createdAt: Date; retries: number;} class PWADatabase extends Dexie { tasks!: Table<Task>; syncQueue!: Table<SyncQueue>; constructor() { super('PWATaskApp'); this.version(1).stores({ tasks: '++id, serverId, syncStatus, updatedAt', syncQueue: '++id, operation, entityType, createdAt' }); }} const db = new PWADatabase(); // CRUD operations with sync awarenessexport async function createTask(task: Omit<Task, 'id' | 'syncStatus'>): Promise<Task> { const newTask: Task = { ...task, syncStatus: 'pending', createdAt: new Date(), updatedAt: new Date(), }; // 1. Save to local database immediately const id = await db.tasks.add(newTask); newTask.id = id; // 2. Queue for sync await db.syncQueue.add({ operation: 'create', entityType: 'task', entityId: id, payload: newTask, createdAt: new Date(), retries: 0, }); // 3. Trigger sync if online if (navigator.onLine) { requestSync(); } return newTask;} // Request Background Sync (Chrome/Edge)function requestSync(): void { if ('serviceWorker' in navigator && 'sync' in ServiceWorkerRegistration.prototype) { navigator.serviceWorker.ready.then(registration => { (registration as any).sync.register('sync-tasks'); }); } else { // Fallback: sync immediately syncNow(); }}Background Sync API:
Background Sync allows deferring actions until connectivity is available:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869
// Service Worker background sync handler// sw.ts self.addEventListener('sync', (event: SyncEvent) => { if (event.tag === 'sync-tasks') { event.waitUntil(syncPendingTasks()); }}); async function syncPendingTasks(): Promise<void> { const db = await openDatabase(); const pendingItems = await db.syncQueue.toArray(); for (const item of pendingItems) { try { const response = await fetch('/api/tasks', { method: getMethod(item.operation), headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(item.payload), }); if (response.ok) { // Success: Update local record with server data const serverData = await response.json(); await db.tasks.update(item.entityId, { serverId: serverData.id, syncStatus: 'synced', }); // Remove from sync queue await db.syncQueue.delete(item.id); } else if (response.status === 409) { // Conflict: Mark for user resolution await db.tasks.update(item.entityId, { syncStatus: 'conflict', }); } else if (response.status >= 500) { // Server error: Retry later throw new Error('Server error, will retry'); } } catch (error) { // Network error: Will be retried by background sync console.log('Sync failed, will retry:', error); throw error; // Re-throw to signal sync should retry } }} // Periodic Background Sync (for keeping data fresh)self.addEventListener('periodicsync', (event: PeriodicSyncEvent) => { if (event.tag === 'refresh-data') { event.waitUntil(refreshDataFromServer()); }}); async function refreshDataFromServer(): Promise<void> { const response = await fetch('/api/tasks/updated-since?since=' + getLastSyncTime()); const updates = await response.json(); const db = await openDatabase(); for (const update of updates) { await db.tasks.put({ ...update, syncStatus: 'synced', }); } await setLastSyncTime(Date.now());}Background Sync is only supported in Chrome and Edge. Safari does not support it at all. For cross-browser PWAs, implement fallback sync: sync on app launch, sync on visibility change, and sync before app closes (using visibilitychange and beforeunload events).
PWA support varies dramatically across platforms. Understanding these differences is critical for setting realistic expectations and making informed architecture decisions.
Android (Chrome): The Gold Standard
Android Chrome offers the most complete PWA support:
iOS (Safari): Significant Limitations
iOS/Safari has historically lagged in PWA support. While improving, significant gaps remain:
| Feature | Android Chrome | iOS Safari | Desktop Chrome |
|---|---|---|---|
| Service Workers | ✓ | ✓ | ✓ |
| IndexedDB | ✓ | ✓ (eviction risk) | ✓ |
| Push Notifications | ✓ | ✓ (16.4+, limited) | ✓ |
| Background Sync | ✓ | ✗ | ✓ |
| Periodic Background Sync | ✓ | ✗ | ✓ |
| App Install Prompt | ✓ | ✗ (manual only) | ✓ |
| Badging API | ✓ | ✗ | ✓ |
| Share Target | ✓ | ✗ | ✓ |
| File System Access | ✓ | ✗ | ✓ |
Practical Implications:
These differences mean:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162
// Platform and feature detection for PWAs export const PWA_CAPABILITIES = { // Core PWA serviceWorker: 'serviceWorker' in navigator, // Offline/storage indexedDB: 'indexedDB' in window, cacheAPI: 'caches' in window, // Background features backgroundSync: 'serviceWorker' in navigator && 'sync' in ServiceWorkerRegistration.prototype, periodicSync: 'serviceWorker' in navigator && 'periodicSync' in ServiceWorkerRegistration.prototype, // Notifications pushManager: 'PushManager' in window, // Advanced badging: 'setAppBadge' in navigator, shareTarget: 'share' in navigator, fileSystem: 'showOpenFilePicker' in window, // Platform detection isIOS: /iPad|iPhone|iPod/.test(navigator.userAgent), isAndroid: /Android/.test(navigator.userAgent), isStandalone: window.matchMedia('(display-mode: standalone)').matches || (window.navigator as any).standalone === true,}; // Adapt behavior based on platformexport function setupSync(): void { if (PWA_CAPABILITIES.backgroundSync) { // Chrome/Edge: Use Background Sync setupBackgroundSync(); } else { // Safari/Firefox: Fallback sync strategy setupFallbackSync(); }} function setupFallbackSync(): void { // Sync when app becomes visible document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') { syncPendingChanges(); } }); // Sync before leaving window.addEventListener('beforeunload', () => { syncPendingChangesSync(); // Synchronous, best-effort }); // Periodic sync when active setInterval(() => { if (document.visibilityState === 'visible') { syncPendingChanges(); } }, 30000);}Apple has been improving PWA support recently (Push Notifications in 16.4, better storage handling). The gap is narrowing, but slowly. Design for iOS limitations today while staying aware of improvements. If iOS is critical to your audience and you need advanced PWA features, consider native or check back in 1-2 years.
Successful PWAs follow established architecture patterns that maximize offline capability and performance.
Pattern 1: App Shell Architecture
The App Shell is the minimum HTML, CSS, and JavaScript required to power the user interface. It's cached on first visit and loads instantly on subsequent visits.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051
// App Shell Pattern // 1. The shell: minimal, cached, instantfunction AppShell({ children }: { children: React.ReactNode }) { return ( <div className="app-shell"> <Header /> {/* Static, cached */} <Navigation /> {/* Static, cached */} <main> {children} {/* Dynamic content loads here */} </main> <TabBar /> {/* Static, cached */} </div> );} // 2. Dynamic content loads after shellfunction FeedPage() { const { data, isLoading } = useQuery('feed', fetchFeed); return ( <AppShell> {isLoading ? ( <FeedSkeleton /> // Show immediately while loading ) : ( <FeedList items={data} /> )} </AppShell> );} // 3. Render skeleton inline in shell for FOUC-free loading// Initial HTML includes shell + skeleton// <body>// <div class="app-shell">// <header>...</header>// <main>// <div class="skeleton">Loading...</div>// </main>// <nav>...</nav>// </div>// <script src="app.js"></script>// </body> // Service worker precaches the shell// sw.jsprecacheAndRoute([ { url: '/', revision: BUILD_ID }, { url: '/app.js', revision: BUILD_ID }, { url: '/app.css', revision: BUILD_ID },]);Pattern 2: PRPL Pattern
PRPL optimizes for fast initial delivery and fast subsequent loads:
Pattern 3: Offline-First Architecture
Design as if offline is the default state:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859
// Offline-First PWA Architecture class OfflineFirstApp { private db: LocalDatabase; private syncManager: SyncManager; // All data operations go through local database first async createTask(task: TaskInput): Promise<Task> { // 1. Save locally FIRST (always works, even offline) const localTask = await this.db.tasks.add({ ...task, id: crypto.randomUUID(), syncStatus: 'pending', localCreatedAt: Date.now(), }); // 2. Optimistically update UI this.emit('task:created', localTask); // 3. Queue for sync (async, non-blocking) this.syncManager.queue('create', 'task', localTask); // 4. Return immediately (user doesn't wait) return localTask; } async getTasks(): Promise<Task[]> { // Always read from local database (instant, offline-capable) return this.db.tasks.toArray(); } // Background sync happens automatically when online setupAutoSync(): void { // Sync when coming online window.addEventListener('online', () => { this.syncManager.syncAll(); }); // Sync when app becomes visible (iOS fallback) document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible' && navigator.onLine) { this.syncManager.syncAll(); } }); // Try sync on launch if (navigator.onLine) { this.syncManager.syncAll(); } }} // Data flow:// CREATE: User Action → Local DB → UI Update → Background Sync → Server// READ: UI Request → Local DB → Display (server checked in background)// // This inverts the typical thin client flow:// Thin client: User Action → Server → Local → UI (blocks on network)// PWA offline-first: User Action → Local → UI (network is background)Use Chrome's Lighthouse tool to audit your PWA. It checks all PWA requirements, performance metrics, accessibility, and SEO. Target a Lighthouse PWA score of 100—it's achievable and means you've covered the fundamentals correctly.
PWAs are excellent for specific use cases but wrong for others. Let's establish clear decision criteria.
PWAs Excel When:
| Requirement | Traditional Web | PWA | Native/RN/Flutter |
|---|---|---|---|
| Offline reading | ✗ | ✓ | ✓ |
| Offline editing w/ sync | ✗ | ⚠ Android best | ✓ |
| Push notifications | ✗ | ⚠ iOS 16.4+ | ✓ |
| Install to home screen | ✗ | ✓ | ✓ |
| App store presence | ✗ | ✗ (TWA possible) | ✓ |
| Background refresh | ✗ | ⚠ Android only | ✓ |
| Hardware access | Limited | Limited | ✓ |
| Development speed | ✓ | ✓ | Slower |
| Update deployment | Instant | Instant | App store review |
The Hybrid Option: PWA + Native Wrapper
For some use cases, you can build a PWA and wrap it in a native shell for app store distribution:
This gives you web deployment benefits + app store presence + access to native features via bridges.
Consider PWA as a layer on top of a solid web app, not a separate product. Start with a fast, responsive web app. Add Service Worker for offline. Add manifest for installability. Each step enhances without replacing. Users on capable platforms get more; users on limited platforms still get a working app.
Let's walk through the practical steps to build a production PWA.
Step 1: Framework Integration
Most modern frameworks have PWA plugins:
| Framework | PWA Solution | Notes |
|---|---|---|
| Next.js | next-pwa or @serwist/next | Automatic SW generation |
| Vite | vite-plugin-pwa | Excellent Workbox integration |
| React (CRA) | Built-in (legacy) | Manual configuration recommended |
| Vue/Nuxt | @vite-pwa/nuxt | First-class support |
| Angular | @angular/pwa | ng add @angular/pwa |
| SvelteKit | vite-plugin-pwa adapter | Works great |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172
// Vite PWA plugin configuration exampleimport { defineConfig } from 'vite';import react from '@vitejs/plugin-react';import { VitePWA } from 'vite-plugin-pwa'; export default defineConfig({ plugins: [ react(), VitePWA({ registerType: 'autoUpdate', includeAssets: ['favicon.ico', 'robots.txt', 'images/*.png'], manifest: { name: 'My PWA App', short_name: 'MyApp', description: 'A progressive web application', theme_color: '#1a1a2e', background_color: '#1a1a2e', display: 'standalone', icons: [ { src: 'icons/icon-192.png', sizes: '192x192', type: 'image/png' }, { src: 'icons/icon-512.png', sizes: '512x512', type: 'image/png' }, { src: 'icons/icon-maskable.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' } ] }, workbox: { // Precache all static assets globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'], // Runtime caching for API calls runtimeCaching: [ { urlPattern: /^https:\/\/api\.example\.com\/.*/i, handler: 'NetworkFirst', options: { cacheName: 'api-cache', expiration: { maxEntries: 100, maxAgeSeconds: 60 * 60 * 24, // 24 hours }, }, }, { urlPattern: /^https:\/\/images\.example\.com\/.*/i, handler: 'CacheFirst', options: { cacheName: 'image-cache', expiration: { maxEntries: 200, maxAgeSeconds: 60 * 60 * 24 * 30, // 30 days }, }, }, ], }, }), ],});Step 2: Testing Your PWA
Use these tools to verify PWA implementation:
Step 3: Deployment Checklist
Don't try to implement every PWA feature at once. Start with: 1) Manifest for installability, 2) Service Worker with basic caching, 3) Offline page. Get those working first. Then add IndexedDB, Background Sync, and advanced features incrementally.
We've explored Progressive Web Apps comprehensively—from core technologies to platform differences to practical implementation. Let's consolidate the essential takeaways:
Module Complete:
With this page, we've completed our exploration of Thin Client vs Thick Client Architecture. You now understand:
This knowledge equips you to make informed architectural decisions about where processing should happen in your systems—a foundational skill for system design.
You've completed the Thin Client vs Thick Client module. You now possess a comprehensive understanding of client architecture choices—from minimal thin clients to sophisticated offline-first thick clients, and the PWA middle ground. Apply this knowledge to choose the right architecture for your users' needs, constraints, and expectations.