Progressive Web App Implementation
1. Service Worker Workbox Caching
Implement service workers with Google Workbox for offline functionality, caching strategies, and background resource management.
| Strategy | Use Case | Behavior | Fallback |
|---|---|---|---|
| Network First | API calls, dynamic content | Try network, fallback to cache | Stale data on offline |
| Cache First | Static assets (CSS, JS, images) | Serve from cache, update in background | Fast but potentially stale |
| Stale While Revalidate | Frequently updated content | Serve cache, fetch fresh copy | Balance speed & freshness |
| Network Only | Real-time data, auth requests | Always fetch from network | Fail if offline |
| Cache Only | Pre-cached app shell | Only serve from cache | Fast, no network needed |
Example: Workbox service worker setup
// Installation
npm install workbox-webpack-plugin --save-dev
// webpack.config.js
const WorkboxPlugin = require('workbox-webpack-plugin');
module.exports = {
plugins: [
new WorkboxPlugin.GenerateSW({
clientsClaim: true,
skipWaiting: true,
runtimeCaching: [
{
urlPattern: /^https:\/\/api\.example\.com/,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
expiration: {
maxEntries: 50,
maxAgeSeconds: 5 * 60 // 5 minutes
},
networkTimeoutSeconds: 10
}
},
{
urlPattern: /\.(?:png|jpg|jpeg|svg|gif)$/,
handler: 'CacheFirst',
options: {
cacheName: 'image-cache',
expiration: {
maxEntries: 60,
maxAgeSeconds: 30 * 24 * 60 * 60 // 30 days
}
}
},
{
urlPattern: /\.(?:js|css)$/,
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'static-resources'
}
}
]
})
]
};
// Register service worker in app
// index.tsx
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker
.register('/service-worker.js')
.then(registration => {
console.log('SW registered:', registration);
// Check for updates
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
newWorker?.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
// New version available
showUpdateNotification();
}
});
});
})
.catch(error => {
console.error('SW registration failed:', error);
});
});
}
Example: Custom Workbox service worker
// service-worker.js
import { precacheAndRoute, cleanupOutdatedCaches } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { NetworkFirst, CacheFirst, StaleWhileRevalidate } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
import { CacheableResponsePlugin } from 'workbox-cacheable-response';
// Clean up old caches
cleanupOutdatedCaches();
// Precache assets from build
precacheAndRoute(self.__WB_MANIFEST);
// Cache API responses with NetworkFirst
registerRoute(
({ url }) => url.pathname.startsWith('/api/'),
new NetworkFirst({
cacheName: 'api-cache',
plugins: [
new CacheableResponsePlugin({
statuses: [0, 200]
}),
new ExpirationPlugin({
maxEntries: 50,
maxAgeSeconds: 5 * 60,
purgeOnQuotaError: true
})
]
})
);
// Cache images with CacheFirst
registerRoute(
({ request }) => request.destination === 'image',
new CacheFirst({
cacheName: 'image-cache',
plugins: [
new ExpirationPlugin({
maxEntries: 100,
maxAgeSeconds: 30 * 24 * 60 * 60
})
]
})
);
// Cache Google Fonts with StaleWhileRevalidate
registerRoute(
({ url }) => url.origin === 'https://fonts.googleapis.com',
new StaleWhileRevalidate({
cacheName: 'google-fonts-stylesheets'
})
);
// Offline fallback page
const FALLBACK_HTML_URL = '/offline.html';
const CACHE_NAME = 'offline-fallbacks';
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.add(FALLBACK_HTML_URL))
);
});
self.addEventListener('fetch', (event) => {
if (event.request.mode === 'navigate') {
event.respondWith(
fetch(event.request).catch(() => {
return caches.match(FALLBACK_HTML_URL);
})
);
}
});
Example: React hook for service worker updates
// useServiceWorker.ts
import { useEffect, useState } from 'react';
export function useServiceWorker() {
const [updateAvailable, setUpdateAvailable] = useState(false);
const [registration, setRegistration] = useState<ServiceWorkerRegistration | null>(null);
useEffect(() => {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/service-worker.js').then((reg) => {
setRegistration(reg);
// Check for updates every hour
setInterval(() => {
reg.update();
}, 60 * 60 * 1000);
reg.addEventListener('updatefound', () => {
const newWorker = reg.installing;
newWorker?.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
setUpdateAvailable(true);
}
});
});
});
}
}, []);
const updateServiceWorker = () => {
if (registration?.waiting) {
registration.waiting.postMessage({ type: 'SKIP_WAITING' });
window.location.reload();
}
};
return { updateAvailable, updateServiceWorker };
}
// Usage in component
function App() {
const { updateAvailable, updateServiceWorker } = useServiceWorker();
return (
<div>
{updateAvailable && (
<div className="update-banner">
<p>A new version is available!</p>
<button onClick={updateServiceWorker}>Update Now</button>
</div>
)}
{/* App content */}
</div>
);
}
2. Web App Manifest Installation
Configure Web App Manifest for installable PWA with app-like experience, custom icons, splash screens, and display modes.
| Property | Purpose | Required | Example |
|---|---|---|---|
| name | Full app name | Yes | "My Progressive Web App" |
| short_name | Home screen name | Yes | "My PWA" |
| icons | App icons (various sizes) | Yes | 192x192, 512x512 PNG |
| start_url | Launch URL | Yes | "/?source=pwa" |
| display | Display mode | Yes | standalone, fullscreen |
| theme_color | Browser UI color | No | "#007acc" |
| background_color | Splash screen color | No | "#ffffff" |
| orientation | Screen orientation | No | portrait, landscape |
Example: Complete manifest.json configuration
// public/manifest.json
{
"name": "My Progressive Web Application",
"short_name": "My PWA",
"description": "A modern progressive web app with offline support",
"start_url": "/?source=pwa",
"display": "standalone",
"theme_color": "#007acc",
"background_color": "#ffffff",
"orientation": "portrait-primary",
"icons": [
{
"src": "/icons/icon-72x72.png",
"sizes": "72x72",
"type": "image/png",
"purpose": "any"
},
{
"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-152x152.png",
"sizes": "152x152",
"type": "image/png"
},
{
"src": "/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/icon-384x384.png",
"sizes": "384x384",
"type": "image/png"
},
{
"src": "/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"screenshots": [
{
"src": "/screenshots/desktop.png",
"sizes": "1280x720",
"type": "image/png",
"form_factor": "wide"
},
{
"src": "/screenshots/mobile.png",
"sizes": "750x1334",
"type": "image/png",
"form_factor": "narrow"
}
],
"categories": ["productivity", "utilities"],
"shortcuts": [
{
"name": "New Document",
"short_name": "New",
"description": "Create a new document",
"url": "/new?source=pwa",
"icons": [{ "src": "/icons/new.png", "sizes": "96x96" }]
},
{
"name": "Dashboard",
"short_name": "Dashboard",
"description": "View your dashboard",
"url": "/dashboard?source=pwa",
"icons": [{ "src": "/icons/dashboard.png", "sizes": "96x96" }]
}
],
"share_target": {
"action": "/share",
"method": "POST",
"enctype": "multipart/form-data",
"params": {
"title": "title",
"text": "text",
"url": "url",
"files": [
{
"name": "media",
"accept": ["image/*", "video/*"]
}
]
}
},
"protocol_handlers": [
{
"protocol": "web+myapp",
"url": "/handle?url=%s"
}
]
}
// Add to HTML head
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#007acc">
<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="My PWA">
<link rel="apple-touch-icon" href="/icons/icon-192x192.png">
Example: Install prompt handling with React
// useInstallPrompt.ts
import { useEffect, useState } from 'react';
interface BeforeInstallPromptEvent extends Event {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
}
export function useInstallPrompt() {
const [installPrompt, setInstallPrompt] = useState<BeforeInstallPromptEvent | null>(null);
const [isInstalled, setIsInstalled] = useState(false);
useEffect(() => {
const handleBeforeInstall = (e: Event) => {
e.preventDefault();
setInstallPrompt(e as BeforeInstallPromptEvent);
};
const handleAppInstalled = () => {
setIsInstalled(true);
setInstallPrompt(null);
};
window.addEventListener('beforeinstallprompt', handleBeforeInstall);
window.addEventListener('appinstalled', handleAppInstalled);
// Check if already installed
if (window.matchMedia('(display-mode: standalone)').matches) {
setIsInstalled(true);
}
return () => {
window.removeEventListener('beforeinstallprompt', handleBeforeInstall);
window.removeEventListener('appinstalled', handleAppInstalled);
};
}, []);
const promptInstall = async () => {
if (!installPrompt) return false;
await installPrompt.prompt();
const { outcome } = await installPrompt.userChoice;
if (outcome === 'accepted') {
setInstallPrompt(null);
return true;
}
return false;
};
return { canInstall: !!installPrompt, isInstalled, promptInstall };
}
// Usage in component
function InstallButton() {
const { canInstall, isInstalled, promptInstall } = useInstallPrompt();
if (isInstalled) {
return <p>✓ App installed</p>;
}
if (!canInstall) {
return null;
}
return (
<button onClick={promptInstall}>
📱 Install App
</button>
);
}
Example: Vite PWA plugin setup
// Installation
npm install -D vite-plugin-pwa
// vite.config.ts
import { defineConfig } from 'vite';
import { VitePWA } from 'vite-plugin-pwa';
export default defineConfig({
plugins: [
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['favicon.ico', 'robots.txt', 'apple-touch-icon.png'],
manifest: {
name: 'My PWA App',
short_name: 'PWA',
description: 'Progressive Web Application',
theme_color: '#007acc',
icons: [
{
src: '/icon-192.png',
sizes: '192x192',
type: 'image/png'
},
{
src: '/icon-512.png',
sizes: '512x512',
type: 'image/png'
}
]
},
workbox: {
runtimeCaching: [
{
urlPattern: /^https:\/\/api\./,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
expiration: {
maxEntries: 50,
maxAgeSeconds: 300
}
}
}
]
}
})
]
});
3. Push Notifications Web Push
Implement push notifications with Web Push API and service workers for re-engagement, updates, and real-time alerts.
| Component | Purpose | Technology | Required |
|---|---|---|---|
| VAPID Keys | Server authentication | Public/private key pair | Yes |
| Push Subscription | User permission & endpoint | PushManager API | Yes |
| Service Worker | Receive notifications | Push event listener | Yes |
| Push Server | Send notifications | web-push library | Yes |
| Notification | Display message | Notification API | Yes |
Example: Frontend push notification setup
// usePushNotifications.ts
import { useState, useEffect } from 'react';
const VAPID_PUBLIC_KEY = 'YOUR_PUBLIC_VAPID_KEY';
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)));
}
export function usePushNotifications() {
const [subscription, setSubscription] = useState<PushSubscription | null>(null);
const [permission, setPermission] = useState<NotificationPermission>('default');
useEffect(() => {
if ('Notification' in window) {
setPermission(Notification.permission);
}
}, []);
const subscribe = async () => {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
throw new Error('Push notifications not supported');
}
// Request permission
const permission = await Notification.requestPermission();
setPermission(permission);
if (permission !== 'granted') {
throw new Error('Permission denied');
}
// Get service worker registration
const registration = await navigator.serviceWorker.ready;
// Subscribe to push notifications
const sub = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY)
});
setSubscription(sub);
// Send subscription to backend
await fetch('/api/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(sub)
});
return sub;
};
const unsubscribe = async () => {
if (!subscription) return;
await subscription.unsubscribe();
// Notify backend
await fetch('/api/push/unsubscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ endpoint: subscription.endpoint })
});
setSubscription(null);
};
return { subscription, permission, subscribe, unsubscribe };
}
// Usage in component
function NotificationSettings() {
const { permission, subscribe, unsubscribe, subscription } = usePushNotifications();
const handleEnable = async () => {
try {
await subscribe();
alert('Notifications enabled!');
} catch (error) {
console.error('Failed to subscribe:', error);
}
};
if (permission === 'denied') {
return <p>Notifications blocked. Enable in browser settings.</p>;
}
return (
<div>
{subscription ? (
<button onClick={unsubscribe}>Disable Notifications</button>
) : (
<button onClick={handleEnable}>Enable Notifications</button>
)}
</div>
);
}
Example: Service worker push event handler
// service-worker.js
self.addEventListener('push', (event) => {
if (!event.data) return;
const data = event.data.json();
const { title, body, icon, badge, image, tag, actions, url } = data;
const options = {
body,
icon: icon || '/icons/icon-192x192.png',
badge: badge || '/icons/badge-72x72.png',
image,
tag: tag || 'default-tag',
requireInteraction: false,
vibrate: [200, 100, 200],
data: { url: url || '/' },
actions: actions || [
{ action: 'open', title: 'Open', icon: '/icons/open.png' },
{ action: 'close', title: 'Close', icon: '/icons/close.png' }
]
};
event.waitUntil(
self.registration.showNotification(title, options)
);
});
// Handle notification click
self.addEventListener('notificationclick', (event) => {
event.notification.close();
if (event.action === 'close') {
return;
}
const url = event.notification.data?.url || '/';
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true })
.then((clientList) => {
// Check if window is already open
for (const client of clientList) {
if (client.url === url && 'focus' in client) {
return client.focus();
}
}
// Open new window
if (clients.openWindow) {
return clients.openWindow(url);
}
})
);
});
// Handle notification close
self.addEventListener('notificationclose', (event) => {
console.log('Notification closed:', event.notification.tag);
// Track analytics
fetch('/api/analytics/notification-close', {
method: 'POST',
body: JSON.stringify({ tag: event.notification.tag })
});
});
Example: Backend push notification server (Node.js)
// Installation
npm install web-push
// Generate VAPID keys (run once)
const webpush = require('web-push');
const vapidKeys = webpush.generateVAPIDKeys();
console.log('Public Key:', vapidKeys.publicKey);
console.log('Private Key:', vapidKeys.privateKey);
// server.js
const express = require('express');
const webpush = require('web-push');
const app = express();
app.use(express.json());
// Configure VAPID keys
webpush.setVapidDetails(
'mailto:your-email@example.com',
process.env.VAPID_PUBLIC_KEY,
process.env.VAPID_PRIVATE_KEY
);
// Store subscriptions (use database in production)
const subscriptions = new Map();
// Subscribe endpoint
app.post('/api/push/subscribe', (req, res) => {
const subscription = req.body;
subscriptions.set(subscription.endpoint, subscription);
res.status(201).json({ success: true });
});
// Unsubscribe endpoint
app.post('/api/push/unsubscribe', (req, res) => {
const { endpoint } = req.body;
subscriptions.delete(endpoint);
res.json({ success: true });
});
// Send notification
app.post('/api/push/send', async (req, res) => {
const { title, body, url, userId } = req.body;
const payload = JSON.stringify({
title,
body,
icon: '/icons/icon-192x192.png',
badge: '/icons/badge-72x72.png',
url,
actions: [
{ action: 'open', title: 'View' },
{ action: 'close', title: 'Dismiss' }
]
});
// Send to all subscriptions (or filter by userId)
const promises = Array.from(subscriptions.values()).map(subscription =>
webpush.sendNotification(subscription, payload)
.catch(error => {
// Handle expired subscriptions
if (error.statusCode === 410) {
subscriptions.delete(subscription.endpoint);
}
console.error('Push error:', error);
})
);
await Promise.all(promises);
res.json({ success: true, sent: promises.length });
});
app.listen(3000, () => console.log('Server running on port 3000'));
4. Background Sync Offline Queue
Implement Background Sync API to defer actions until network connectivity is restored, ensuring reliable data submission.
| Feature | Purpose | Use Case | Browser Support |
|---|---|---|---|
| Background Sync | Retry failed requests | Form submissions, messages | Chrome, Edge |
| Periodic Sync | Regular background updates | News, content refresh | Limited support |
| SyncManager | Register sync events | Sync orchestration | Chrome, Edge |
Example: Background Sync for offline form submission
// useBackgroundSync.ts
import { useState } from 'react';
export function useBackgroundSync() {
const [pending, setPending] = useState<number>(0);
const queueSync = async (tag: string, data: any) => {
if (!('serviceWorker' in navigator) || !('SyncManager' in window)) {
// Fallback: immediate submission
return fetch('/api/submit', {
method: 'POST',
body: JSON.stringify(data),
headers: { 'Content-Type': 'application/json' }
});
}
// Store data in IndexedDB
const db = await openDatabase();
await db.add('sync-queue', { tag, data, timestamp: Date.now() });
// Register background sync
const registration = await navigator.serviceWorker.ready;
await registration.sync.register(tag);
setPending(prev => prev + 1);
};
return { queueSync, pending };
}
// service-worker.js
self.addEventListener('sync', (event) => {
if (event.tag.startsWith('form-submit-')) {
event.waitUntil(syncFormData(event.tag));
} else if (event.tag === 'message-queue') {
event.waitUntil(syncMessages());
}
});
async function syncFormData(tag) {
const db = await openDatabase();
const items = await db.getAllFromIndex('sync-queue', 'by-tag', tag);
for (const item of items) {
try {
const response = await fetch('/api/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(item.data)
});
if (response.ok) {
await db.delete('sync-queue', item.id);
// Notify clients
const clients = await self.clients.matchAll();
clients.forEach(client => {
client.postMessage({
type: 'SYNC_SUCCESS',
tag: item.tag
});
});
} else {
throw new Error('Server error');
}
} catch (error) {
console.error('Sync failed:', error);
// Will retry automatically
}
}
}
async function syncMessages() {
const db = await openDatabase();
const messages = await db.getAll('message-queue');
for (const message of messages) {
try {
await fetch('/api/messages', {
method: 'POST',
body: JSON.stringify(message),
headers: { 'Content-Type': 'application/json' }
});
await db.delete('message-queue', message.id);
} catch (error) {
console.error('Message sync failed:', error);
}
}
}
Example: Offline-first form component
// OfflineForm.tsx
import { useState } from 'react';
import { useBackgroundSync } from './useBackgroundSync';
export function OfflineForm() {
const [formData, setFormData] = useState({ name: '', email: '', message: '' });
const [status, setStatus] = useState<'idle' | 'submitting' | 'queued'>('idle');
const { queueSync } = useBackgroundSync();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setStatus('submitting');
try {
if (navigator.onLine) {
// Online: submit immediately
const response = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData)
});
if (response.ok) {
alert('Form submitted successfully!');
setFormData({ name: '', email: '', message: '' });
}
} else {
// Offline: queue for background sync
await queueSync('form-submit-contact', formData);
setStatus('queued');
alert('You are offline. Form will be submitted when connection is restored.');
}
} catch (error) {
// Network error: queue for sync
await queueSync('form-submit-contact', formData);
setStatus('queued');
alert('Submission queued. Will retry automatically.');
} finally {
setStatus('idle');
}
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={formData.name}
onChange={e => setFormData({ ...formData, name: e.target.value })}
placeholder="Name"
required
/>
<input
type="email"
value={formData.email}
onChange={e => setFormData({ ...formData, email: e.target.value })}
placeholder="Email"
required
/>
<textarea
value={formData.message}
onChange={e => setFormData({ ...formData, message: e.target.value })}
placeholder="Message"
required
/>
<button type="submit" disabled={status === 'submitting'}>
{status === 'submitting' ? 'Submitting...' :
status === 'queued' ? 'Queued for Sync' :
'Submit'}
</button>
</form>
);
}
Example: Periodic Background Sync (Chrome only)
// Register periodic sync (requires user engagement)
async function registerPeriodicSync() {
const registration = await navigator.serviceWorker.ready;
if ('periodicSync' in registration) {
try {
await registration.periodicSync.register('content-sync', {
minInterval: 24 * 60 * 60 * 1000 // 24 hours
});
console.log('Periodic sync registered');
} catch (error) {
console.error('Periodic sync registration failed:', error);
}
}
}
// service-worker.js
self.addEventListener('periodicsync', (event) => {
if (event.tag === 'content-sync') {
event.waitUntil(syncContent());
}
});
async function syncContent() {
try {
// Fetch latest content
const response = await fetch('/api/content/latest');
const content = await response.json();
// Update cache
const cache = await caches.open('content-cache');
await cache.put('/api/content/latest', new Response(JSON.stringify(content)));
// Notify clients
const clients = await self.clients.matchAll();
clients.forEach(client => {
client.postMessage({
type: 'CONTENT_UPDATED',
count: content.length
});
});
} catch (error) {
console.error('Periodic sync failed:', error);
}
}
// Unregister periodic sync
async function unregisterPeriodicSync() {
const registration = await navigator.serviceWorker.ready;
if ('periodicSync' in registration) {
await registration.periodicSync.unregister('content-sync');
}
}
5. IndexedDB Storage Management
Use IndexedDB for client-side structured data storage with queries, indexes, and transactions for offline-first apps.
| Library | Features | API Style | Bundle Size |
|---|---|---|---|
| idb (Jake Archibald) | Promise wrapper, simple API | Modern async/await | ~1KB |
| Dexie.js | Rich queries, relationships | Declarative | ~20KB |
| LocalForage | localStorage-like API | Simple key-value | ~8KB |
| PouchDB | CouchDB sync, replication | Document database | ~140KB |
Example: IndexedDB with idb library (recommended)
// Installation
npm install idb
// db.ts - Database setup
import { openDB, DBSchema, IDBPDatabase } from 'idb';
interface MyDB extends DBSchema {
products: {
key: string;
value: {
id: string;
name: string;
price: number;
category: string;
createdAt: Date;
};
indexes: { 'by-category': string; 'by-price': number };
};
cart: {
key: string;
value: {
productId: string;
quantity: number;
addedAt: Date;
};
};
syncQueue: {
key: number;
value: {
action: string;
data: any;
timestamp: number;
};
};
}
let dbPromise: Promise<IDBPDatabase<MyDB>> | null = null;
export function getDB() {
if (!dbPromise) {
dbPromise = openDB<MyDB>('my-pwa-db', 1, {
upgrade(db) {
// Create products store
const productStore = db.createObjectStore('products', { keyPath: 'id' });
productStore.createIndex('by-category', 'category');
productStore.createIndex('by-price', 'price');
// Create cart store
db.createObjectStore('cart', { keyPath: 'productId' });
// Create sync queue store
db.createObjectStore('syncQueue', { keyPath: 'id', autoIncrement: true });
}
});
}
return dbPromise;
}
// products.ts - CRUD operations
export async function addProduct(product: MyDB['products']['value']) {
const db = await getDB();
await db.add('products', product);
}
export async function getProduct(id: string) {
const db = await getDB();
return db.get('products', id);
}
export async function getAllProducts() {
const db = await getDB();
return db.getAll('products');
}
export async function getProductsByCategory(category: string) {
const db = await getDB();
return db.getAllFromIndex('products', 'by-category', category);
}
export async function updateProduct(product: MyDB['products']['value']) {
const db = await getDB();
await db.put('products', product);
}
export async function deleteProduct(id: string) {
const db = await getDB();
await db.delete('products', id);
}
// Advanced queries with cursor
export async function getProductsInPriceRange(min: number, max: number) {
const db = await getDB();
const range = IDBKeyRange.bound(min, max);
return db.getAllFromIndex('products', 'by-price', range);
}
// Bulk operations with transaction
export async function bulkAddProducts(products: MyDB['products']['value'][]) {
const db = await getDB();
const tx = db.transaction('products', 'readwrite');
await Promise.all([
...products.map(product => tx.store.add(product)),
tx.done
]);
}
Example: React hook for IndexedDB
// useIndexedDB.ts
import { useState, useEffect } from 'react';
import { getDB } from './db';
import type { DBSchema } from 'idb';
export function useIndexedDB<T>(storeName: string) {
const [data, setData] = useState<T[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
loadData();
}, [storeName]);
const loadData = async () => {
try {
setLoading(true);
const db = await getDB();
const items = await db.getAll(storeName as any);
setData(items as T[]);
setError(null);
} catch (err) {
setError(err as Error);
} finally {
setLoading(false);
}
};
const add = async (item: T) => {
try {
const db = await getDB();
await db.add(storeName as any, item);
await loadData();
} catch (err) {
setError(err as Error);
}
};
const update = async (item: T) => {
try {
const db = await getDB();
await db.put(storeName as any, item);
await loadData();
} catch (err) {
setError(err as Error);
}
};
const remove = async (key: string | number) => {
try {
const db = await getDB();
await db.delete(storeName as any, key);
await loadData();
} catch (err) {
setError(err as Error);
}
};
const clear = async () => {
try {
const db = await getDB();
await db.clear(storeName as any);
setData([]);
} catch (err) {
setError(err as Error);
}
};
return { data, loading, error, add, update, remove, clear, refresh: loadData };
}
// Usage in component
function ProductList() {
const { data: products, loading, add, remove } = useIndexedDB<Product>('products');
if (loading) return <div>Loading...</div>;
return (
<div>
<h2>Products ({products.length})</h2>
<ul>
{products.map(product => (
<li key={product.id}>
{product.name} - ${product.price}
<button onClick={() => remove(product.id)}>Delete</button>
</li>
))}
</ul>
<button onClick={() => add({ id: Date.now().toString(), name: 'New Product', price: 99 })}>
Add Product
</button>
</div>
);
}
Example: Dexie.js for advanced queries
// Installation
npm install dexie
// db.ts
import Dexie, { Table } from 'dexie';
interface Product {
id?: number;
name: string;
price: number;
category: string;
tags: string[];
inStock: boolean;
}
class MyDatabase extends Dexie {
products!: Table<Product>;
constructor() {
super('MyAppDB');
this.version(1).stores({
products: '++id, name, price, category, *tags, inStock'
// ++ = auto-increment
// * = multi-entry index (for arrays)
});
}
}
export const db = new MyDatabase();
// Complex queries
export async function searchProducts(query: string) {
return db.products
.where('name')
.startsWithIgnoreCase(query)
.or('category')
.equalsIgnoreCase(query)
.toArray();
}
export async function getInStockProducts() {
return db.products.where('inStock').equals(1).toArray();
}
export async function getProductsByTags(tags: string[]) {
return db.products.where('tags').anyOf(tags).toArray();
}
export async function getAffordableProducts(maxPrice: number) {
return db.products.where('price').below(maxPrice).toArray();
}
// Bulk operations
export async function bulkUpdate() {
await db.products.where('category').equals('Electronics').modify({
inStock: false
});
}
// Transactions
export async function transferStock(fromProductId: number, toProductId: number) {
await db.transaction('rw', db.products, async () => {
const fromProduct = await db.products.get(fromProductId);
const toProduct = await db.products.get(toProductId);
if (fromProduct && toProduct) {
await db.products.update(fromProductId, { inStock: false });
await db.products.update(toProductId, { inStock: true });
}
});
}
// Hooks integration
import { useLiveQuery } from 'dexie-react-hooks';
function ProductList() {
const products = useLiveQuery(() => db.products.toArray());
if (!products) return <div>Loading...</div>;
return (
<ul>
{products.map(p => (
<li key={p.id}>{p.name}</li>
))}
</ul>
);
}
6. App Shell Architecture Performance
Implement App Shell pattern for instant loading with cached static shell and dynamic content for optimal perceived performance.
| Component | Caching Strategy | Priority | Update Frequency |
|---|---|---|---|
| App Shell (HTML/CSS/JS) | Cache First, pre-cache | Critical | On deployment |
| Static Assets | Cache First | High | On version change |
| Dynamic Content | Network First | Medium | Real-time |
| API Responses | Stale While Revalidate | Medium | Background |
| User Data | IndexedDB | High | On change |
Example: App Shell structure with React
// App shell components (always cached)
// AppShell.tsx
import { Suspense, lazy } from 'react';
import { Header } from './Header'; // Pre-cached
import { Sidebar } from './Sidebar'; // Pre-cached
import { Footer } from './Footer'; // Pre-cached
// Dynamic content (loaded separately)
const DynamicContent = lazy(() => import('./DynamicContent'));
export function AppShell() {
return (
<div className="app-shell">
<Header />
<div className="main-content">
<Sidebar />
<main>
<Suspense fallback={<SkeletonLoader />}>
<DynamicContent />
</Suspense>
</main>
</div>
<Footer />
</div>
);
}
// index.html - Minimal critical HTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>My PWA</title>
<link rel="manifest" href="/manifest.json">
<!-- Inline critical CSS for instant render -->
<style>
.app-shell { display: flex; flex-direction: column; min-height: 100vh; }
.skeleton { background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%); }
/* ... minimal styles ... */
</style>
</head>
<body>
<div id="root">
<!-- Initial shell renders immediately from cache -->
<div class="app-shell">
<header class="skeleton"></header>
<main class="skeleton"></main>
<footer class="skeleton"></footer>
</div>
</div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
Example: Service worker for App Shell caching
// service-worker.js with Workbox
import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute, NavigationRoute } from 'workbox-routing';
import { NetworkFirst, CacheFirst, StaleWhileRevalidate } from 'workbox-strategies';
// Pre-cache app shell
const APP_SHELL_CACHE = 'app-shell-v1';
const appShellFiles = [
'/',
'/index.html',
'/styles/main.css',
'/scripts/app.js',
'/offline.html'
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(APP_SHELL_CACHE).then((cache) => {
return cache.addAll(appShellFiles);
})
);
self.skipWaiting();
});
// Serve app shell for all navigation requests
registerRoute(
new NavigationRoute(
new NetworkFirst({
cacheName: APP_SHELL_CACHE,
plugins: [
{
cacheWillUpdate: async ({ response }) => {
// Only cache successful responses
return response.status === 200 ? response : null;
}
}
]
})
)
);
// Cache static assets
registerRoute(
({ request }) => request.destination === 'style' ||
request.destination === 'script' ||
request.destination === 'font',
new CacheFirst({
cacheName: 'static-assets-v1',
plugins: [
new ExpirationPlugin({
maxEntries: 60,
maxAgeSeconds: 30 * 24 * 60 * 60 // 30 days
})
]
})
);
// Dynamic content with network priority
registerRoute(
({ url }) => url.pathname.startsWith('/api/'),
new NetworkFirst({
cacheName: 'api-cache-v1',
plugins: [
new ExpirationPlugin({
maxEntries: 50,
maxAgeSeconds: 5 * 60 // 5 minutes
})
]
})
);
// Images with cache priority
registerRoute(
({ request }) => request.destination === 'image',
new CacheFirst({
cacheName: 'image-cache-v1',
plugins: [
new ExpirationPlugin({
maxEntries: 100,
maxAgeSeconds: 7 * 24 * 60 * 60 // 7 days
})
]
})
);
Example: Performance optimization with App Shell
// SkeletonLoader.tsx - Immediate UI feedback
export function SkeletonLoader() {
return (
<div className="skeleton-container">
<div className="skeleton skeleton-text" style={{ width: '60%' }} />
<div className="skeleton skeleton-text" style={{ width: '80%' }} />
<div className="skeleton skeleton-text" style={{ width: '70%' }} />
<div className="skeleton skeleton-card" />
</div>
);
}
// Performance monitoring
export function measureAppShellPerformance() {
if ('performance' in window) {
window.addEventListener('load', () => {
const perfData = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
const metrics = {
// Time to First Byte
ttfb: perfData.responseStart - perfData.requestStart,
// DOM Content Loaded
domContentLoaded: perfData.domContentLoadedEventEnd - perfData.domContentLoadedEventStart,
// Full page load
loadComplete: perfData.loadEventEnd - perfData.loadEventStart,
// App shell render (custom)
appShellRender: performance.now()
};
// Send to analytics
console.log('App Shell Performance:', metrics);
// Report to backend
navigator.sendBeacon('/api/analytics/performance', JSON.stringify(metrics));
});
}
}
// useAppShellCache.ts - React hook
import { useEffect, useState } from 'react';
export function useAppShellCache() {
const [cacheStatus, setCacheStatus] = useState<'checking' | 'cached' | 'error'>('checking');
useEffect(() => {
checkAppShellCache();
}, []);
const checkAppShellCache = async () => {
try {
const cache = await caches.open('app-shell-v1');
const cachedRequests = await cache.keys();
if (cachedRequests.length > 0) {
setCacheStatus('cached');
} else {
setCacheStatus('error');
}
} catch (error) {
setCacheStatus('error');
}
};
const clearCache = async () => {
await caches.delete('app-shell-v1');
window.location.reload();
};
return { cacheStatus, clearCache };
}
PWA Implementation Checklist
| Component | Implementation | Testing | Impact |
|---|---|---|---|
| Service Worker | Workbox with caching strategies | DevTools offline mode | Offline functionality |
| Web Manifest | Complete manifest.json, install prompt | Lighthouse PWA audit | Installability |
| Push Notifications | VAPID keys, push subscription, handlers | Push notification test | Re-engagement |
| Background Sync | SyncManager, offline queue | Offline form submit test | Reliability |
| IndexedDB | idb/Dexie for structured storage | Storage quota checks | Data persistence |
| App Shell | Pre-cached shell, dynamic content | Performance metrics | Fast loading |