Progressive Web App Implementation Stack
1. Service Worker Workbox Caching Strategies
| Strategy | Description & Use Cases | Implementation | Best Practices |
|---|---|---|---|
| Cache First (CacheFirst) | Check cache first, network fallback. Best for: static assets, fonts, images, CSS, JS bundles | Workbox: new CacheFirst({ cacheName, plugins }). Max age 1 year. Cache on install |
Use for immutable assets. Set max-age 1yr. Cache bust with hashes. Limit cache size 50MB |
| Network First (NetworkFirst) | Try network, cache fallback. Best for: API calls, dynamic content, news feeds, user data | new NetworkFirst({ cacheName, networkTimeoutSeconds: 3 }). Timeout to cache |
Use for fresh data priority. 3s timeout. Cache as backup. Good for offline experience |
| Stale While Revalidate | Return cache, update background. Best for: avatars, non-critical content, analytics | new StaleWhileRevalidate({ cacheName }). Instant response + background update |
Best UX for semi-fresh data. Fast response. Update silently. Use for profile pics, icons |
| Network Only | Always network, never cache. Best for: POST/PUT/DELETE, payments, auth, real-time data | new NetworkOnly(). No caching. Fail if offline. Critical data only |
Use for mutations. Payment APIs. Auth endpoints. Never cache sensitive data |
| Cache Only | Only from cache, no network. Best for: offline-first, precached shells, fallback pages | new CacheOnly({ cacheName }). Must precache. Offline page. App shell |
Precache on install. Offline fallback page. App shell. Never stale content |
| Workbox Strategies | Plugin system, expiration, cache size, broadcast updates, background sync | ExpirationPlugin (maxAge, maxEntries). CacheableResponsePlugin. BroadcastUpdatePlugin | Limit cache 50MB. Expire after 30d. Cache 200 responses only. Broadcast cache updates |
Example: Workbox service worker with caching strategies
// service-worker.js
import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import {
CacheFirst,
NetworkFirst,
StaleWhileRevalidate,
NetworkOnly
} from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
import { CacheableResponsePlugin } from 'workbox-cacheable-response';
// Precache app shell and static assets
precacheAndRoute(self.__WB_MANIFEST);
// Cache static assets (images, fonts, CSS, JS)
registerRoute(
({ request }) => ['image', 'font', 'style', 'script'].includes(request.destination),
new CacheFirst({
cacheName: 'static-assets-v1',
plugins: [
new CacheableResponsePlugin({
statuses: [0, 200],
}),
new ExpirationPlugin({
maxEntries: 60,
maxAgeSeconds: 30 * 24 * 60 * 60, // 30 days
purgeOnQuotaError: true,
}),
],
})
);
// Cache API responses with network first
registerRoute(
({ url }) => url.pathname.startsWith('/api/'),
new NetworkFirst({
cacheName: 'api-cache-v1',
networkTimeoutSeconds: 3,
plugins: [
new CacheableResponsePlugin({
statuses: [200],
}),
new ExpirationPlugin({
maxEntries: 50,
maxAgeSeconds: 5 * 60, // 5 minutes
}),
],
})
);
// Stale-while-revalidate for avatars and images
registerRoute(
({ url }) => url.pathname.match(/\.(jpg|jpeg|png|gif|webp)$/),
new StaleWhileRevalidate({
cacheName: 'images-v1',
plugins: [
new ExpirationPlugin({
maxEntries: 100,
maxAgeSeconds: 7 * 24 * 60 * 60, // 7 days
}),
],
})
);
// Network only for authentication and mutations
registerRoute(
({ url, request }) =>
url.pathname.startsWith('/api/auth') ||
['POST', 'PUT', 'DELETE'].includes(request.method),
new NetworkOnly()
);
// Offline fallback page
const OFFLINE_URL = '/offline.html';
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open('offline-v1').then((cache) => cache.add(OFFLINE_URL))
);
});
self.addEventListener('fetch', (event) => {
if (event.request.mode === 'navigate') {
event.respondWith(
fetch(event.request).catch(() =>
caches.match(OFFLINE_URL)
)
);
}
});
Example: Register service worker in React
// src/serviceWorkerRegistration.ts
export function register() {
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker
.register('/service-worker.js')
.then((registration) => {
console.log('SW registered:', registration);
// Check for updates every hour
setInterval(() => {
registration.update();
}, 60 * 60 * 1000);
// Listen for updates
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
newWorker?.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
// New content available, show update prompt
if (confirm('New version available! Reload to update?')) {
newWorker.postMessage({ type: 'SKIP_WAITING' });
window.location.reload();
}
}
});
});
})
.catch((error) => {
console.error('SW registration failed:', error);
});
});
}
}
// src/main.tsx
import { register } from './serviceWorkerRegistration';
register();
2. Web App Manifest PWA Installation
| Property | Description | Example Values | Best Practices |
|---|---|---|---|
| name | Full app name shown on splash screen and app list. 45 characters max | "My Awesome PWA Application" |
Descriptive, brandable. Same as page title. Avoid generic names |
| short_name | Short name for home screen. 12 characters max fits all devices | "MyPWA" |
Keep under 12 chars. Still recognizable. Used as icon label. Test on devices |
| icons | App icons for home screen, splash screen. Multiple sizes required | 192x192, 512x512 (maskable), 180x180 (iOS) | Provide 192x192 (Android), 512x512 (splash), maskable icons. PNG format. Transparent |
| start_url | URL loaded when app launches. Track PWA installs with query param | "/?source=pwa" |
Add UTM params for analytics. Must be in scope. Relative path preferred |
| display | Display mode: standalone, fullscreen, minimal-ui, browser | "standalone" |
Use standalone for app-like. fullscreen for games. minimal-ui for tools. Browser fallback |
| theme_color & background_color | Theme: status bar color. Background: splash screen while loading | "#1976d2", "#ffffff" |
Match brand colors. Theme for browser UI. Background for splash. Update meta tags |
Example: Complete manifest.json
// public/manifest.json
{
"name": "My Progressive Web App",
"short_name": "MyPWA",
"description": "A modern progressive web application with offline support",
"start_url": "/?source=pwa",
"scope": "/",
"display": "standalone",
"orientation": "portrait-primary",
"theme_color": "#1976d2",
"background_color": "#ffffff",
"icons": [
{
"src": "/icons/icon-72x72.png",
"sizes": "72x72",
"type": "image/png"
},
{
"src": "/icons/icon-96x96.png",
"sizes": "96x96",
"type": "image/png"
},
{
"src": "/icons/icon-128x128.png",
"sizes": "128x128",
"type": "image/png"
},
{
"src": "/icons/icon-144x144.png",
"sizes": "144x144",
"type": "image/png"
},
{
"src": "/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-maskable-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/icons/icon-maskable-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"screenshots": [
{
"src": "/screenshots/desktop-1.png",
"sizes": "1280x720",
"type": "image/png",
"form_factor": "wide"
},
{
"src": "/screenshots/mobile-1.png",
"sizes": "750x1334",
"type": "image/png",
"form_factor": "narrow"
}
],
"categories": ["productivity", "utilities"],
"lang": "en-US",
"dir": "ltr",
"prefer_related_applications": false
}
// index.html - Link manifest and meta tags
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- PWA Manifest -->
<link rel="manifest" href="/manifest.json">
<!-- Theme colors -->
<meta name="theme-color" content="#1976d2">
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#121212">
<!-- iOS specific -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="MyPWA">
<link rel="apple-touch-icon" href="/icons/icon-180x180.png">
<!-- Windows specific -->
<meta name="msapplication-TileColor" content="#1976d2">
<meta name="msapplication-TileImage" content="/icons/icon-144x144.png">
</head>
</html>
Example: Install prompt handler
// React component for install prompt
import { useState, useEffect } from 'react';
export function InstallPWA() {
const [deferredPrompt, setDeferredPrompt] = useState<any>(null);
const [showInstall, setShowInstall] = useState(false);
useEffect(() => {
const handler = (e: Event) => {
e.preventDefault();
setDeferredPrompt(e);
setShowInstall(true);
};
window.addEventListener('beforeinstallprompt', handler);
// Check if already installed
if (window.matchMedia('(display-mode: standalone)').matches) {
setShowInstall(false);
}
return () => window.removeEventListener('beforeinstallprompt', handler);
}, []);
const handleInstall = async () => {
if (!deferredPrompt) return;
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
console.log(`User ${outcome === 'accepted' ? 'accepted' : 'dismissed'} install`);
setDeferredPrompt(null);
setShowInstall(false);
};
if (!showInstall) return null;
return (
<div className="install-banner">
<p>Install our app for better experience!</p>
<button onClick={handleInstall}>Install</button>
<button onClick={() => setShowInstall(false)}>Dismiss</button>
</div>
);
}
3. Push Notifications Web Push Protocol
| Component | Purpose | Implementation | Best Practices |
|---|---|---|---|
| VAPID Keys | Voluntary Application Server Identification. Public/private key pair for auth | Generate: web-push generate-vapid-keys. Store private securely. Public in client |
Never expose private key. Rotate yearly. Store in env vars. Use same keys across envs |
| Push Subscription | User permission, get subscription endpoint, send to server, store in DB | registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey }) |
Request permission contextually. Explain value. Fallback if denied. Resubscribe on expire |
| Push Server | Send notifications via web-push library. Payload max 4KB. Encryption automatic | Node: webpush.sendNotification(subscription, payload). Python: pywebpush |
Batch sends for efficiency. Retry on failure. Remove invalid subscriptions. Rate limit |
| Notification Payload | Title, body, icon, badge, image, actions, tag, data, silent notifications | { title, body, icon, badge, image, actions: [{ action, title }], data } |
Title 50 chars, body 120 chars. Icon 192x192. Actions max 2. Tag for grouping. Rich image |
| Service Worker Handler | Listen for push event. Show notification. Handle click. Open app or URL | self.addEventListener('push', event). registration.showNotification() |
Always show notification (userVisibleOnly). Handle click. Track engagement. Badge count |
| User Experience | Timely, relevant, precise. Opt-in, not spam. Allow disable. Track click-through | Contextual permission prompt. Settings to manage. Unsubscribe easy. Analytics | Max 1-2 per day. Personalized. Actionable. Test before sending. A/B test messages |
Example: Client-side push subscription
// utils/pushNotifications.ts
const PUBLIC_VAPID_KEY = 'BNXz...'; // Your public VAPID key
export async function subscribeToPush(): Promise<PushSubscription | null> {
try {
// Check permission
const permission = await Notification.requestPermission();
if (permission !== 'granted') {
console.log('Notification permission denied');
return null;
}
// Get service worker registration
const registration = await navigator.serviceWorker.ready;
// Check existing subscription
let subscription = await registration.pushManager.getSubscription();
if (!subscription) {
// Create new subscription
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(PUBLIC_VAPID_KEY),
});
}
// Send subscription to server
await fetch('/api/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(subscription),
});
return subscription;
} catch (error) {
console.error('Push subscription failed:', error);
return null;
}
}
function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding)
.replace(/\-/g, '+')
.replace(/_/g, '/');
const rawData = window.atob(base64);
return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0)));
}
// React component
export function NotificationButton() {
const [isSubscribed, setIsSubscribed] = useState(false);
const handleSubscribe = async () => {
const subscription = await subscribeToPush();
setIsSubscribed(!!subscription);
};
return (
<button onClick={handleSubscribe} disabled={isSubscribed}>
{isSubscribed ? 'Notifications Enabled' : 'Enable Notifications'}
</button>
);
}
Example: Service worker push handler and server
// service-worker.js - Handle push events
self.addEventListener('push', (event) => {
const data = event.data?.json() ?? {};
const { title, body, icon, badge, image, actions, url } = data;
const options = {
body,
icon: icon || '/icons/icon-192x192.png',
badge: badge || '/icons/badge-72x72.png',
image,
actions: actions || [],
data: { url },
tag: data.tag || 'notification',
requireInteraction: false,
vibrate: [200, 100, 200],
};
event.waitUntil(
self.registration.showNotification(title, options)
);
});
// Handle notification click
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const urlToOpen = event.notification.data?.url || '/';
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true })
.then((clientList) => {
// Focus existing window if available
for (const client of clientList) {
if (client.url === urlToOpen && 'focus' in client) {
return client.focus();
}
}
// Open new window
if (clients.openWindow) {
return clients.openWindow(urlToOpen);
}
})
);
});
// server.js - Send push notification
import webpush from 'web-push';
webpush.setVapidDetails(
'mailto:admin@example.com',
process.env.VAPID_PUBLIC_KEY,
process.env.VAPID_PRIVATE_KEY
);
// Store subscriptions in database
const subscriptions = new Map();
app.post('/api/push/subscribe', (req, res) => {
const subscription = req.body;
subscriptions.set(subscription.endpoint, subscription);
res.status(201).json({ success: true });
});
// Send notification
app.post('/api/push/send', async (req, res) => {
const { title, body, url } = req.body;
const payload = JSON.stringify({ title, body, url });
const promises = Array.from(subscriptions.values()).map((subscription) =>
webpush.sendNotification(subscription, payload)
.catch((error) => {
if (error.statusCode === 410) {
// Subscription expired, remove it
subscriptions.delete(subscription.endpoint);
}
console.error('Send failed:', error);
})
);
await Promise.all(promises);
res.json({ sent: promises.length });
});
4. Background Sync Offline Queue
| Feature | Use Cases | Implementation | Best Practices |
|---|---|---|---|
| Background Sync API | Retry failed requests, Offline form submissions, Send analytics, Upload files when online | registration.sync.register('sync-tag'). Service worker listens to sync event |
Use for non-urgent data. Retry failed requests. Queue operations. Expire after 24h |
| Workbox Background Sync | Automatic retry queue, Exponential backoff, Replay requests, Plugin system | new BackgroundSyncPlugin('queue-name', { maxRetentionTime: 24 * 60 }) |
Use workbox for simplicity. Configure retry logic. Max retention 24h. Monitor queue size |
| Offline Queue | Store failed requests, Retry when online, Show pending operations, User feedback | IndexedDB for queue. Online/offline events. Retry on network restore. UI indicators | Show pending badge. Allow user to cancel. Preserve order. Handle conflicts. Sync on restore |
| Periodic Background Sync | Fetch fresh content, Update cache, Background uploads, Data synchronization | registration.periodicSync.register('tag', { minInterval: 24 * 60 * 60 * 1000 }) |
Min 12h interval. User engagement required. Battery-conscious. Update content proactively |
| Conflict Resolution | Handle stale data, Merge changes, Last-write-wins, Operational transforms | Version vectors, timestamps, CRDTs for complex merges. Server validates | Timestamp all changes. Server arbitrates conflicts. User chooses on conflict. Preserve both |
| User Feedback | Show sync status, Pending operations count, Retry notifications, Error handling | Badge for pending. Toast on sync. Retry button. Show errors with details | Visual feedback essential. Allow manual retry. Show progress. Clear success/failure |
Example: Background sync with Workbox
// service-worker.js
import { BackgroundSyncPlugin } from 'workbox-background-sync';
import { registerRoute } from 'workbox-routing';
import { NetworkOnly } from 'workbox-strategies';
// Create background sync plugin
const bgSyncPlugin = new BackgroundSyncPlugin('api-queue', {
maxRetentionTime: 24 * 60, // 24 hours in minutes
onSync: async ({ queue }) => {
let entry;
while ((entry = await queue.shiftRequest())) {
try {
await fetch(entry.request.clone());
console.log('Replay successful:', entry.request.url);
} catch (error) {
console.error('Replay failed:', error);
await queue.unshiftRequest(entry);
throw error;
}
}
},
});
// Register route for API mutations
registerRoute(
({ url, request }) =>
url.pathname.startsWith('/api/') &&
['POST', 'PUT', 'DELETE', 'PATCH'].includes(request.method),
new NetworkOnly({
plugins: [bgSyncPlugin],
}),
'POST' // Method
);
// Listen to sync event (native Background Sync API)
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-posts') {
event.waitUntil(syncPosts());
}
});
async function syncPosts() {
const db = await openDatabase();
const pendingPosts = await db.getAll('pending-posts');
for (const post of pendingPosts) {
try {
const response = await fetch('/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(post.data),
});
if (response.ok) {
await db.delete('pending-posts', post.id);
}
} catch (error) {
console.error('Sync failed:', error);
}
}
}
Example: Client-side offline queue manager
// offlineQueue.ts
import { openDB, DBSchema } from 'idb';
interface QueueItem {
id: string;
url: string;
method: string;
headers: Record<string, string>;
body: string;
timestamp: number;
retries: number;
}
interface QueueDB extends DBSchema {
'queue': {
key: string;
value: QueueItem;
indexes: { 'timestamp': number };
};
}
class OfflineQueue {
private db!: IDBPDatabase<QueueDB>;
async init() {
this.db = await openDB<QueueDB>('offline-queue', 1, {
upgrade(db) {
const store = db.createObjectStore('queue', { keyPath: 'id' });
store.createIndex('timestamp', 'timestamp');
},
});
// Listen for online event
window.addEventListener('online', () => this.processQueue());
}
async add(url: string, options: RequestInit): Promise<void> {
const item: QueueItem = {
id: crypto.randomUUID(),
url,
method: options.method || 'GET',
headers: options.headers as Record<string, string>,
body: options.body as string,
timestamp: Date.now(),
retries: 0,
};
await this.db.add('queue', item);
// Try to process immediately
if (navigator.onLine) {
this.processQueue();
}
}
async processQueue(): Promise<void> {
const items = await this.db.getAll('queue');
for (const item of items) {
try {
const response = await fetch(item.url, {
method: item.method,
headers: item.headers,
body: item.body,
});
if (response.ok) {
await this.db.delete('queue', item.id);
console.log('Synced:', item.url);
} else if (response.status >= 400 && response.status < 500) {
// Client error, don't retry
await this.db.delete('queue', item.id);
} else {
// Server error, retry
item.retries++;
if (item.retries > 5) {
await this.db.delete('queue', item.id);
} else {
await this.db.put('queue', item);
}
}
} catch (error) {
console.error('Queue processing failed:', error);
}
}
}
async getPendingCount(): Promise<number> {
return (await this.db.getAll('queue')).length;
}
}
export const offlineQueue = new OfflineQueue();
// Usage in React
export function useOfflineQueue() {
const [pendingCount, setPendingCount] = useState(0);
useEffect(() => {
const updateCount = async () => {
const count = await offlineQueue.getPendingCount();
setPendingCount(count);
};
updateCount();
const interval = setInterval(updateCount, 5000);
return () => clearInterval(interval);
}, []);
return { pendingCount };
}
5. Cache API Storage Management
| Feature | Purpose | API & Usage | Best Practices |
|---|---|---|---|
| Cache Storage API | Store Request/Response pairs, Multiple named caches, Version control, Programmatic control | caches.open(name), .put(), .match(), .delete() |
Version cache names. Clean old versions. Use separate caches by type. Max 50MB per origin |
| Cache Versioning | Update strategies, Migrate data, Remove old caches, Prevent conflicts | Cache names: app-v1, app-v2. Delete old on activate |
Version in name. Delete old on SW activate. Atomic updates. Test migration path |
| Storage Quota | Check available space, Request persistent storage, Monitor usage, Eviction policy | navigator.storage.estimate(), .persist(), quota exceeded events |
Check quota before caching. Request persist for critical data. Handle quota errors gracefully |
| IndexedDB | Structured data storage, Large datasets, Complex queries, Transactions, Indexes | idb library for promises. Stores, indexes, cursors. 50MB+ storage | Use idb wrapper. Version schema. Index frequently queried. Transaction for consistency |
| LocalStorage & SessionStorage | Simple key-value, Synchronous, 5-10MB limit, String only, Tab/session scope | localStorage.setItem(key, value), .getItem(). JSON stringify objects |
Use for small data. Not for sensitive data. Synchronous blocks. Prefer IndexedDB/Cache |
| Storage Management | Clear old data, Handle quota errors, Prioritize important data, Eviction strategy | LRU eviction. Timestamp entries. Clear on version update. Monitor usage | Set expiration. Clear stale data weekly. Prioritize app shell. User control to clear |
Example: Cache management in service worker
// service-worker.js
const CACHE_VERSION = 'v3';
const CACHE_NAMES = {
static: `static-${CACHE_VERSION}`,
dynamic: `dynamic-${CACHE_VERSION}`,
images: `images-${CACHE_VERSION}`,
};
const STATIC_ASSETS = [
'/',
'/index.html',
'/styles.css',
'/app.js',
'/manifest.json',
'/offline.html',
];
// Install - precache static assets
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAMES.static)
.then((cache) => cache.addAll(STATIC_ASSETS))
.then(() => self.skipWaiting())
);
});
// Activate - clean up old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => !Object.values(CACHE_NAMES).includes(name))
.map((name) => {
console.log('Deleting old cache:', name);
return caches.delete(name);
})
);
}).then(() => self.clients.claim())
);
});
// Check and manage storage quota
async function checkStorageQuota() {
if ('storage' in navigator && 'estimate' in navigator.storage) {
const { usage, quota } = await navigator.storage.estimate();
const percentUsed = (usage / quota) * 100;
console.log(`Storage: ${usage} / ${quota} bytes (${percentUsed.toFixed(2)}%)`);
if (percentUsed > 80) {
// Clean up old caches
await cleanupOldEntries();
}
}
}
async function cleanupOldEntries() {
const cache = await caches.open(CACHE_NAMES.dynamic);
const requests = await cache.keys();
// Delete oldest 25% of entries
const toDelete = Math.floor(requests.length * 0.25);
for (let i = 0; i < toDelete; i++) {
await cache.delete(requests[i]);
}
}
Example: Storage quota monitoring
// storageManager.ts
export class StorageManager {
async getStorageInfo() {
if (!('storage' in navigator)) {
return { usage: 0, quota: 0, percentage: 0, isPersisted: false };
}
const estimate = await navigator.storage.estimate();
const usage = estimate.usage || 0;
const quota = estimate.quota || 0;
const percentage = (usage / quota) * 100;
const isPersisted = await navigator.storage.persisted();
return { usage, quota, percentage, isPersisted };
}
async requestPersistentStorage(): Promise<boolean> {
if ('storage' in navigator && 'persist' in navigator.storage) {
return await navigator.storage.persist();
}
return false;
}
formatBytes(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
}
async clearAllStorage() {
// Clear all caches
const cacheNames = await caches.keys();
await Promise.all(cacheNames.map(name => caches.delete(name)));
// Clear IndexedDB
const databases = await indexedDB.databases();
databases.forEach(db => indexedDB.deleteDatabase(db.name!));
// Clear localStorage
localStorage.clear();
sessionStorage.clear();
console.log('All storage cleared');
}
}
// React component to show storage info
export function StorageMonitor() {
const [info, setInfo] = useState<any>(null);
useEffect(() => {
const manager = new StorageManager();
const update = async () => {
const storageInfo = await manager.getStorageInfo();
setInfo(storageInfo);
};
update();
const interval = setInterval(update, 30000); // Update every 30s
return () => clearInterval(interval);
}, []);
if (!info) return null;
const manager = new StorageManager();
return (
<div className="storage-monitor">
<h3>Storage Usage</h3>
<p>{manager.formatBytes(info.usage)} / {manager.formatBytes(info.quota)}</p>
<p>{info.percentage.toFixed(2)}% used</p>
<p>Persistent: {info.isPersisted ? 'Yes' : 'No'}</p>
{info.percentage > 80 && (
<p className="warning">Storage almost full!</p>
)}
</div>
);
}
6. App Shell Architecture Performance
| Concept | Description | Implementation | Performance Benefits |
|---|---|---|---|
| App Shell Pattern | Minimal HTML/CSS/JS for UI shell. Cache shell, load content dynamically. Instant load | Precache shell assets. Critical CSS inline. Lazy load content. Route-based code split | Sub-1s load. FCP <1s. TTI <3s. 90+ Lighthouse score. Perceived performance boost |
| Critical Rendering Path | Inline critical CSS, Defer non-critical JS, Async fonts, Preload key resources | <style> critical CSS. <link rel="preload">. async/defer scripts. font-display: swap | FCP <1s. No render-blocking. Progressive rendering. Fast first paint. Better UX |
| Code Splitting | Split by route, feature, vendor. Lazy load on demand. Reduce initial bundle | React.lazy, dynamic import(), route-based splitting. Webpack chunks. Vite code split | Initial bundle <200KB. 50-70% reduction. Faster TTI. Load only what's needed |
| Resource Hints | Preload, prefetch, preconnect, dns-prefetch. Guide browser optimization | <link rel="preload|prefetch|preconnect|dns-prefetch" href="..." /> | Preload critical (fonts, CSS). Prefetch next page. Preconnect APIs. Faster resource load |
| Skeleton Screens | Show UI structure while loading. Better perceived performance than spinners | CSS placeholder. Animate pulse. Match real content layout. Replace with real data | Perceived load 30% faster. Reduce bounce. Professional UX. No jarring layout shifts |
| Performance Budget | Set limits: bundle size, load time, metrics. Enforce in CI. Track over time | Lighthouse CI. webpack-bundle-analyzer. size-limit. Fail build if exceeded | JS <200KB. FCP <1s. TTI <3s. LCP <2.5s. Maintain performance discipline |
Example: App shell HTML structure
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PWA App Shell</title>
<!-- Critical CSS inlined -->
<style>
/* App shell critical styles - inline for instant render */
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, sans-serif; }
.shell-header { height: 56px; background: #1976d2; color: white; }
.shell-nav { width: 200px; background: #f5f5f5; }
.shell-content { flex: 1; }
.skeleton { background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%); }
</style>
<!-- Resource hints -->
<link rel="preconnect" href="https://api.example.com">
<link rel="dns-prefetch" href="https://cdn.example.com">
<link rel="preload" href="/fonts/main.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/app.js" as="script">
<!-- Non-critical CSS -->
<link rel="stylesheet" href="/styles.css" media="print" onload="this.media='all'">
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#1976d2">
</head>
<body>
<!-- App Shell - loads instantly from cache -->
<div id="app-shell">
<header class="shell-header">
<h1>My PWA</h1>
</header>
<div class="shell-layout">
<nav class="shell-nav">
<!-- Navigation skeleton -->
<div class="skeleton" style="height: 40px; margin: 10px;"></div>
<div class="skeleton" style="height: 40px; margin: 10px;"></div>
<div class="skeleton" style="height: 40px; margin: 10px;"></div>
</nav>
<main class="shell-content">
<!-- Content skeleton -->
<div class="skeleton" style="height: 200px; margin: 20px;"></div>
<div class="skeleton" style="height: 100px; margin: 20px;"></div>
</main>
</div>
</div>
<!-- React root - content loaded dynamically -->
<div id="root"></div>
<!-- Defer non-critical JavaScript -->
<script src="/app.js" defer></script>
<script>
// Register service worker
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/service-worker.js');
});
}
</script>
</body>
</html>
Example: React app shell with code splitting
// App.tsx - Route-based code splitting
import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { AppShell } from './components/AppShell';
import { SkeletonLoader } from './components/SkeletonLoader';
// Lazy load route components
const Home = lazy(() => import('./pages/Home'));
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Profile = lazy(() => import('./pages/Profile'));
const Settings = lazy(() => import('./pages/Settings'));
export function App() {
return (
<BrowserRouter>
<AppShell>
<Suspense fallback={<SkeletonLoader />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/profile" element={<Profile />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
</AppShell>
</BrowserRouter>
);
}
// components/AppShell.tsx - Persistent shell
export function AppShell({ children }: { children: React.ReactNode }) {
return (
<>
<Header />
<div className="layout">
<Navigation />
<main className="content">{children}</main>
</div>
<Footer />
</>
);
}
// components/SkeletonLoader.tsx
export function SkeletonLoader() {
return (
<div className="skeleton-container">
<div className="skeleton skeleton-title" />
<div className="skeleton skeleton-text" />
<div className="skeleton skeleton-text" />
<div className="skeleton skeleton-card" />
</div>
);
}
// vite.config.ts - Manual chunks for optimal splitting
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks: {
'react-vendor': ['react', 'react-dom', 'react-router-dom'],
'ui-components': ['./src/components/Button', './src/components/Input'],
},
},
},
},
});
PWA Performance Checklist:
- First Load: FCP <1s, LCP <2.5s, TTI <3s. Lighthouse score 90+
- Bundle Size: Initial JS <200KB. Total <500KB. Use code splitting
- Caching: Cache app shell. Stale-while-revalidate for content. 50MB cache limit
- Offline: All routes work offline. Background sync for mutations. Show network status
- Install: Manifest.json complete. Icons 192x192, 512x512. Install prompt UX
Progressive Web App Summary
- Service Workers: Workbox for caching. CacheFirst for static. NetworkFirst for API. Offline fallback
- Manifest: Complete manifest.json. Icons all sizes. Maskable icons. Install prompt. theme_color
- Push Notifications: VAPID keys. User permission. Payload <4KB. Handle click. 1-2 per day max
- Background Sync: Queue failed requests. Workbox BackgroundSyncPlugin. Retry on online. User feedback
- Storage: Cache API for responses. IndexedDB for data. Monitor quota. LRU eviction. Persistent storage
- App Shell: Instant load shell. Inline critical CSS. Code split routes. Skeleton screens. Sub-1s FCP
PWA Production Standards: Achieve Lighthouse PWA score 100,
offline-capable, installable, sub-3s TTI, push notifications (opt-in), and background sync for resilience. PWA = native-like experience on web.