JavaScript PWA Development: Service Workers, Offline Support, and App-like Experiences
Build Progressive Web Apps with JavaScript. Master service workers, offline functionality, push notifications, and native app-like experiences.
Progressive Web Apps (PWAs) combine the best of web and mobile applications, providing app-like experiences through modern web technologies. Using service workers, web app manifests, and advanced caching strategies, PWAs deliver offline functionality, push notifications, and native app-like performance. This comprehensive guide covers building production-ready PWAs with JavaScript.
PWA Fundamentals and Setup
Service Worker Registration and Management
// PWA Manager for comprehensive PWA functionality
class PWAManager {
constructor() {
this.serviceWorker = null;
this.registration = null;
this.isOnline = navigator.onLine;
this.installPromptEvent = null;
this.updateAvailable = false;
this.setupEventListeners();
}
// Initialize PWA
async initialize() {
try {
// Check PWA support
this.checkPWASupport();
// Register service worker
await this.registerServiceWorker();
// Setup install prompt
this.setupInstallPrompt();
// Setup network monitoring
this.setupNetworkMonitoring();
console.log('PWA initialized successfully');
return true;
} catch (error) {
console.error('PWA initialization failed:', error);
return false;
}
}
// Check PWA support
checkPWASupport() {
const support = {
serviceWorker: 'serviceWorker' in navigator,
pushNotifications: 'PushManager' in window,
notifications: 'Notification' in window,
indexedDB: 'indexedDB' in window,
cache: 'caches' in window,
backgroundSync:
'serviceWorker' in navigator &&
'sync' in window.ServiceWorkerRegistration.prototype,
};
console.log('PWA Support:', support);
return support;
}
// Register service worker
async registerServiceWorker(swPath = '/sw.js') {
if (!('serviceWorker' in navigator)) {
throw new Error('Service workers not supported');
}
try {
this.registration = await navigator.serviceWorker.register(swPath);
console.log('Service Worker registered:', this.registration);
// Handle updates
this.registration.addEventListener('updatefound', () => {
const newWorker = this.registration.installing;
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed') {
if (navigator.serviceWorker.controller) {
// New version available
this.updateAvailable = true;
this.onServiceWorkerUpdate();
} else {
// First time install
this.onServiceWorkerInstall();
}
}
});
});
return this.registration;
} catch (error) {
console.error('Service Worker registration failed:', error);
throw error;
}
}
// Setup install prompt
setupInstallPrompt() {
window.addEventListener('beforeinstallprompt', (event) => {
event.preventDefault();
this.installPromptEvent = event;
this.onInstallPromptReady();
});
window.addEventListener('appinstalled', () => {
this.installPromptEvent = null;
this.onAppInstalled();
});
}
// Setup network monitoring
setupNetworkMonitoring() {
window.addEventListener('online', () => {
this.isOnline = true;
this.onNetworkStatusChange(true);
});
window.addEventListener('offline', () => {
this.isOnline = false;
this.onNetworkStatusChange(false);
});
}
// Setup general event listeners
setupEventListeners() {
// Listen for messages from service worker
navigator.serviceWorker?.addEventListener('message', (event) => {
this.handleServiceWorkerMessage(event.data);
});
}
// Show install prompt
async showInstallPrompt() {
if (!this.installPromptEvent) {
throw new Error('Install prompt not available');
}
const result = await this.installPromptEvent.prompt();
console.log('Install prompt result:', result);
this.installPromptEvent = null;
return result;
}
// Check if app is installed
isAppInstalled() {
return (
window.matchMedia('(display-mode: standalone)').matches ||
window.navigator.standalone === true
);
}
// Send message to service worker
async sendMessageToSW(message) {
if (!this.registration?.active) {
throw new Error('No active service worker');
}
return new Promise((resolve) => {
const messageChannel = new MessageChannel();
messageChannel.port1.onmessage = (event) => {
resolve(event.data);
};
this.registration.active.postMessage(message, [messageChannel.port2]);
});
}
// Update service worker
async updateServiceWorker() {
if (!this.registration) return;
await this.registration.update();
}
// Skip waiting for new service worker
async skipWaiting() {
await this.sendMessageToSW({ action: 'skipWaiting' });
}
// Event handlers (override in subclasses)
onServiceWorkerInstall() {
console.log('Service Worker installed for the first time');
}
onServiceWorkerUpdate() {
console.log('New service worker available');
this.showUpdateNotification();
}
onInstallPromptReady() {
console.log('App can be installed');
this.showInstallButton();
}
onAppInstalled() {
console.log('App installed successfully');
this.showSuccessMessage('App installed successfully!');
}
onNetworkStatusChange(isOnline) {
console.log('Network status changed:', isOnline ? 'online' : 'offline');
this.updateOfflineIndicator(!isOnline);
}
handleServiceWorkerMessage(data) {
console.log('Message from service worker:', data);
switch (data.type) {
case 'CACHE_UPDATED':
this.onCacheUpdated(data.payload);
break;
case 'SYNC_COMPLETE':
this.onSyncComplete(data.payload);
break;
case 'PUSH_RECEIVED':
this.onPushReceived(data.payload);
break;
}
}
onCacheUpdated(payload) {
console.log('Cache updated:', payload);
}
onSyncComplete(payload) {
console.log('Background sync complete:', payload);
}
onPushReceived(payload) {
console.log('Push notification received:', payload);
}
// UI Helper methods
showUpdateNotification() {
const notification = this.createNotification(
'Update Available',
'A new version of the app is ready. Click to update.',
[
{
text: 'Update Now',
action: () => this.skipWaiting().then(() => location.reload()),
},
{ text: 'Later', action: () => notification.remove() },
]
);
}
showInstallButton() {
if (document.getElementById('pwa-install-btn')) return;
const button = document.createElement('button');
button.id = 'pwa-install-btn';
button.textContent = 'Install App';
button.style.cssText = `
position: fixed;
bottom: 20px;
right: 20px;
background: #007bff;
color: white;
border: none;
padding: 12px 24px;
border-radius: 25px;
cursor: pointer;
z-index: 1000;
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
`;
button.addEventListener('click', () => {
this.showInstallPrompt();
button.remove();
});
document.body.appendChild(button);
}
createNotification(title, message, actions = []) {
const notification = document.createElement('div');
notification.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background: white;
border: 1px solid #ddd;
border-radius: 8px;
padding: 16px;
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
z-index: 10000;
max-width: 350px;
`;
const actionsHTML = actions
.map(
(action, index) =>
`<button class="action-btn" data-index="${index}" style="
background: ${index === 0 ? '#007bff' : 'transparent'};
color: ${index === 0 ? 'white' : '#007bff'};
border: 1px solid #007bff;
padding: 6px 12px;
border-radius: 4px;
margin-right: 8px;
cursor: pointer;
">${action.text}</button>`
)
.join('');
notification.innerHTML = `
<h4 style="margin: 0 0 8px 0;">${title}</h4>
<p style="margin: 0 0 12px 0; color: #666;">${message}</p>
<div>${actionsHTML}</div>
`;
// Add action handlers
notification.addEventListener('click', (e) => {
if (e.target.classList.contains('action-btn')) {
const index = parseInt(e.target.dataset.index);
actions[index].action();
}
});
document.body.appendChild(notification);
return notification;
}
updateOfflineIndicator(isOffline) {
let indicator = document.getElementById('offline-indicator');
if (isOffline && !indicator) {
indicator = document.createElement('div');
indicator.id = 'offline-indicator';
indicator.textContent = 'You are offline';
indicator.style.cssText = `
position: fixed;
top: 0;
left: 0;
right: 0;
background: #f39c12;
color: white;
text-align: center;
padding: 8px;
z-index: 9999;
`;
document.body.appendChild(indicator);
} else if (!isOffline && indicator) {
indicator.remove();
}
}
showSuccessMessage(message) {
const toast = document.createElement('div');
toast.textContent = message;
toast.style.cssText = `
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
background: #28a745;
color: white;
padding: 12px 24px;
border-radius: 4px;
z-index: 10000;
`;
document.body.appendChild(toast);
setTimeout(() => {
toast.remove();
}, 3000);
}
}
// Cache Manager for advanced caching strategies
class CacheManager {
constructor() {
this.strategies = {
CACHE_FIRST: 'cache-first',
NETWORK_FIRST: 'network-first',
CACHE_ONLY: 'cache-only',
NETWORK_ONLY: 'network-only',
STALE_WHILE_REVALIDATE: 'stale-while-revalidate',
};
}
// Cache First Strategy
async cacheFirst(request, cacheName = 'dynamic-cache') {
const cache = await caches.open(cacheName);
const cachedResponse = await cache.match(request);
if (cachedResponse) {
return cachedResponse;
}
try {
const networkResponse = await fetch(request);
if (networkResponse.ok) {
cache.put(request, networkResponse.clone());
}
return networkResponse;
} catch (error) {
console.error('Network request failed:', error);
throw error;
}
}
// Network First Strategy
async networkFirst(request, cacheName = 'dynamic-cache') {
const cache = await caches.open(cacheName);
try {
const networkResponse = await fetch(request);
if (networkResponse.ok) {
cache.put(request, networkResponse.clone());
}
return networkResponse;
} catch (error) {
console.warn('Network failed, trying cache:', error);
const cachedResponse = await cache.match(request);
if (cachedResponse) {
return cachedResponse;
}
throw error;
}
}
// Stale While Revalidate Strategy
async staleWhileRevalidate(request, cacheName = 'dynamic-cache') {
const cache = await caches.open(cacheName);
const cachedResponse = await cache.match(request);
// Start network request in background
const networkPromise = fetch(request)
.then((response) => {
if (response.ok) {
cache.put(request, response.clone());
}
return response;
})
.catch((error) => {
console.warn('Background network request failed:', error);
});
// Return cached response immediately if available
if (cachedResponse) {
return cachedResponse;
}
// Wait for network if no cache
return networkPromise;
}
// Cache with expiration
async cacheWithExpiration(request, response, cacheName, maxAge = 86400000) {
const cache = await caches.open(cacheName);
// Add timestamp to response headers
const responseToCache = new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: {
...Object.fromEntries(response.headers.entries()),
'sw-cache-timestamp': Date.now().toString(),
'sw-cache-max-age': maxAge.toString(),
},
});
await cache.put(request, responseToCache);
}
// Check if cached response is expired
async isCacheExpired(cachedResponse, maxAge = 86400000) {
const timestamp = cachedResponse.headers.get('sw-cache-timestamp');
const cacheMaxAge = cachedResponse.headers.get('sw-cache-max-age');
if (!timestamp) return false;
const age = Date.now() - parseInt(timestamp);
const expireTime = parseInt(cacheMaxAge) || maxAge;
return age > expireTime;
}
// Clean expired cache entries
async cleanExpiredEntries(cacheName) {
const cache = await caches.open(cacheName);
const requests = await cache.keys();
for (const request of requests) {
const response = await cache.match(request);
if (response && (await this.isCacheExpired(response))) {
await cache.delete(request);
}
}
}
// Get cache size
async getCacheSize(cacheName) {
const cache = await caches.open(cacheName);
const requests = await cache.keys();
let totalSize = 0;
for (const request of requests) {
const response = await cache.match(request);
if (response) {
const blob = await response.blob();
totalSize += blob.size;
}
}
return totalSize;
}
// Clear cache
async clearCache(cacheName) {
return await caches.delete(cacheName);
}
}
// Generate web app manifest
function generateManifest(config) {
const defaultConfig = {
name: 'My Progressive Web App',
short_name: 'MyPWA',
description: 'A Progressive Web App built with JavaScript',
start_url: '/',
display: 'standalone',
background_color: '#ffffff',
theme_color: '#000000',
orientation: 'portrait-primary',
scope: '/',
lang: 'en',
dir: 'ltr',
};
const manifest = { ...defaultConfig, ...config };
// Add icons if provided
if (config.icons) {
manifest.icons = config.icons.map((icon) => ({
src: icon.src,
sizes: icon.sizes || '192x192',
type: icon.type || 'image/png',
purpose: icon.purpose || 'any maskable',
}));
}
// Add shortcuts if provided
if (config.shortcuts) {
manifest.shortcuts = config.shortcuts.map((shortcut) => ({
name: shortcut.name,
short_name: shortcut.short_name || shortcut.name,
description: shortcut.description,
url: shortcut.url,
icons: shortcut.icons || [],
}));
}
return manifest;
}
// Usage example
const manifest = generateManifest({
name: 'JavaScript PWA Example',
short_name: 'JS PWA',
description: 'A comprehensive Progressive Web App example',
theme_color: '#2196F3',
background_color: '#ffffff',
icons: [
{
src: '/images/icon-192.png',
sizes: '192x192',
type: 'image/png',
},
{
src: '/images/icon-512.png',
sizes: '512x512',
type: 'image/png',
},
],
shortcuts: [
{
name: 'Quick Action',
url: '/quick',
description: 'Perform a quick action',
},
],
});
console.log('Generated manifest:', manifest);
// Initialize PWA
const pwaManager = new PWAManager();
window.pwaManager = pwaManager; // Make globally available
pwaManager.initialize().then((success) => {
if (success) {
console.log('PWA ready!');
} else {
console.log('PWA initialization failed');
}
});
Push Notifications and Offline Storage
Advanced Notification System
// Notification Manager for push notifications
class NotificationManager {
constructor() {
this.permission = Notification.permission;
this.subscription = null;
this.vapidPublicKey = null;
this.serverEndpoint = '/api/notifications';
}
// Initialize notifications
async initialize(vapidPublicKey = null) {
this.vapidPublicKey = vapidPublicKey;
// Check notification support
if (!('Notification' in window)) {
throw new Error('Notifications not supported');
}
// Request permission if needed
if (this.permission === 'default') {
await this.requestPermission();
}
// Subscribe to push notifications if permission granted
if (this.permission === 'granted') {
await this.subscribeToPush();
}
return this.permission === 'granted';
}
// Request notification permission
async requestPermission() {
const permission = await Notification.requestPermission();
this.permission = permission;
if (permission === 'granted') {
console.log('Notification permission granted');
} else if (permission === 'denied') {
console.log('Notification permission denied');
} else {
console.log('Notification permission dismissed');
}
return permission;
}
// Subscribe to push notifications
async subscribeToPush() {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
throw new Error('Push notifications not supported');
}
const registration = await navigator.serviceWorker.ready;
try {
this.subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: this.vapidPublicKey
? this.urlBase64ToUint8Array(this.vapidPublicKey)
: undefined,
});
console.log('Push subscription successful:', this.subscription);
// Send subscription to server
await this.sendSubscriptionToServer();
return this.subscription;
} catch (error) {
console.error('Push subscription failed:', error);
throw error;
}
}
// Send subscription to server
async sendSubscriptionToServer() {
if (!this.subscription) {
throw new Error('No subscription available');
}
const subscriptionData = {
endpoint: this.subscription.endpoint,
keys: {
p256dh: this.arrayBufferToBase64(this.subscription.getKey('p256dh')),
auth: this.arrayBufferToBase64(this.subscription.getKey('auth')),
},
userAgent: navigator.userAgent,
timestamp: Date.now(),
};
try {
const response = await fetch(`${this.serverEndpoint}/subscribe`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(subscriptionData),
});
if (!response.ok) {
throw new Error(`Server responded with ${response.status}`);
}
console.log('Subscription sent to server');
} catch (error) {
console.error('Failed to send subscription to server:', error);
throw error;
}
}
// Show local notification
showNotification(title, options = {}) {
if (this.permission !== 'granted') {
console.warn('Notification permission not granted');
return null;
}
const defaultOptions = {
body: '',
icon: '/images/icon-192.png',
badge: '/images/badge.png',
vibrate: [100, 50, 100],
requireInteraction: false,
silent: false,
tag: 'default',
timestamp: Date.now(),
data: {},
actions: [],
};
const notificationOptions = { ...defaultOptions, ...options };
return new Notification(title, notificationOptions);
}
// Create rich notification with actions
createRichNotification(title, body, actions = []) {
const defaultActions = [
{
action: 'view',
title: 'View',
icon: '/images/view-icon.png',
},
{
action: 'dismiss',
title: 'Dismiss',
icon: '/images/dismiss-icon.png',
},
];
return this.showNotification(title, {
body,
actions: actions.length > 0 ? actions : defaultActions,
requireInteraction: true,
data: {
timestamp: Date.now(),
url: window.location.href,
},
});
}
// Utility functions
urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding)
.replace(/-/g, '+')
.replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
arrayBufferToBase64(buffer) {
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return window.btoa(binary);
}
}
// Offline Storage Manager using IndexedDB
class OfflineStorageManager {
constructor() {
this.dbName = 'PWAOfflineStorage';
this.dbVersion = 1;
this.stores = {
userData: 'userData',
appState: 'appState',
cache: 'cache',
syncQueue: 'syncQueue',
};
this.db = null;
}
// Initialize IndexedDB
async initialize() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.dbVersion);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
this.db = request.result;
resolve(this.db);
};
request.onupgradeneeded = (event) => {
const db = event.target.result;
// Create object stores
Object.values(this.stores).forEach((storeName) => {
if (!db.objectStoreNames.contains(storeName)) {
const store = db.createObjectStore(storeName, {
keyPath: 'id',
autoIncrement: true,
});
store.createIndex('timestamp', 'timestamp', { unique: false });
store.createIndex('key', 'key', { unique: false });
}
});
};
});
}
// Store data
async store(storeName, key, data) {
if (!this.db) {
await this.initialize();
}
const record = {
key,
data,
timestamp: Date.now(),
};
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([storeName], 'readwrite');
const store = transaction.objectStore(storeName);
const request = store.add(record);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
});
}
// Retrieve data
async retrieve(storeName, key) {
if (!this.db) {
await this.initialize();
}
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([storeName], 'readonly');
const store = transaction.objectStore(storeName);
const index = store.index('key');
const request = index.get(key);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
const result = request.result;
resolve(result ? result.data : null);
};
});
}
// Update data
async update(storeName, key, data) {
if (!this.db) {
await this.initialize();
}
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([storeName], 'readwrite');
const store = transaction.objectStore(storeName);
const index = store.index('key');
const getRequest = index.get(key);
getRequest.onerror = () => reject(getRequest.error);
getRequest.onsuccess = () => {
const existing = getRequest.result;
if (existing) {
existing.data = data;
existing.timestamp = Date.now();
const putRequest = store.put(existing);
putRequest.onerror = () => reject(putRequest.error);
putRequest.onsuccess = () => resolve(putRequest.result);
} else {
resolve(null);
}
};
});
}
// Delete data
async delete(storeName, key) {
if (!this.db) {
await this.initialize();
}
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([storeName], 'readwrite');
const store = transaction.objectStore(storeName);
const index = store.index('key');
const getRequest = index.get(key);
getRequest.onerror = () => reject(getRequest.error);
getRequest.onsuccess = () => {
const existing = getRequest.result;
if (existing) {
const deleteRequest = store.delete(existing.id);
deleteRequest.onerror = () => reject(deleteRequest.error);
deleteRequest.onsuccess = () => resolve(true);
} else {
resolve(false);
}
};
});
}
// Get all data from store
async getAll(storeName) {
if (!this.db) {
await this.initialize();
}
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([storeName], 'readonly');
const store = transaction.objectStore(storeName);
const request = store.getAll();
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
});
}
// Clear store
async clear(storeName) {
if (!this.db) {
await this.initialize();
}
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([storeName], 'readwrite');
const store = transaction.objectStore(storeName);
const request = store.clear();
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve();
});
}
}
// Background Sync Manager
class BackgroundSyncManager {
constructor(offlineStorage) {
this.offlineStorage = offlineStorage;
this.storeName = 'syncQueue';
}
// Add request to sync queue
async addToSyncQueue(url, method = 'GET', body = null, headers = {}) {
const request = {
url,
method,
body: body ? JSON.stringify(body) : null,
headers: {
'Content-Type': 'application/json',
...headers,
},
timestamp: Date.now(),
retries: 0,
maxRetries: 3,
};
await this.offlineStorage.store(
this.storeName,
`${Date.now()}_${Math.random()}`,
request
);
// Register background sync
this.registerBackgroundSync();
return request;
}
// Register background sync
async registerBackgroundSync() {
if (
'serviceWorker' in navigator &&
'sync' in window.ServiceWorkerRegistration.prototype
) {
try {
const registration = await navigator.serviceWorker.ready;
await registration.sync.register('background-sync');
console.log('Background sync registered');
} catch (error) {
console.error('Background sync registration failed:', error);
}
} else {
console.warn(
'Background sync not supported, falling back to immediate sync'
);
this.syncPendingData();
}
}
// Sync pending data immediately
async syncPendingData() {
const pendingRequests = await this.offlineStorage.getAll(this.storeName);
for (const requestRecord of pendingRequests) {
const request = requestRecord.data;
try {
const response = await fetch(request.url, {
method: request.method,
body: request.body,
headers: request.headers,
});
if (response.ok) {
await this.offlineStorage.delete(this.storeName, requestRecord.key);
console.log('Sync successful for:', request.url);
} else {
// Increment retries
request.retries++;
if (request.retries >= request.maxRetries) {
await this.offlineStorage.delete(this.storeName, requestRecord.key);
} else {
await this.offlineStorage.update(
this.storeName,
requestRecord.key,
request
);
}
}
} catch (error) {
console.error('Sync failed for:', request.url, error);
// Increment retries
request.retries++;
if (request.retries >= request.maxRetries) {
await this.offlineStorage.delete(this.storeName, requestRecord.key);
} else {
await this.offlineStorage.update(
this.storeName,
requestRecord.key,
request
);
}
}
}
}
// Get sync queue status
async getQueueStatus() {
const pendingRequests = await this.offlineStorage.getAll(this.storeName);
return {
totalRequests: pendingRequests.length,
byMethod: pendingRequests.reduce((acc, req) => {
const method = req.data.method;
acc[method] = (acc[method] || 0) + 1;
return acc;
}, {}),
oldestRequest:
pendingRequests.length > 0
? Math.min(...pendingRequests.map((req) => req.data.timestamp))
: null,
};
}
}
// Usage example
async function initializePWAFeatures() {
// Initialize storage
const offlineStorage = new OfflineStorageManager();
await offlineStorage.initialize();
// Initialize managers
const notificationManager = new NotificationManager();
const syncManager = new BackgroundSyncManager(offlineStorage);
// Example: Store user data for offline access
await offlineStorage.store('userData', 'profile', {
name: 'John Doe',
email: 'john@example.com',
preferences: {
theme: 'dark',
notifications: true,
},
});
// Example: Store app state
await offlineStorage.store('appState', 'currentView', {
route: '/dashboard',
filters: { status: 'active' },
pagination: { page: 1, limit: 20 },
});
console.log('PWA features initialized');
return { offlineStorage, notificationManager, syncManager };
}
initializePWAFeatures()
.then((result) => {
console.log('PWA features ready');
})
.catch((error) => {
console.error('PWA features initialization failed:', error);
});
Service Worker Implementation
Complete Service Worker Template
// Service Worker Implementation - This goes in sw.js file
const SERVICE_WORKER_VERSION = '1.0.0';
const CACHE_NAME = `pwa-cache-v${SERVICE_WORKER_VERSION}`;
const STATIC_CACHE = `static-cache-v${SERVICE_WORKER_VERSION}`;
const DYNAMIC_CACHE = `dynamic-cache-v${SERVICE_WORKER_VERSION}`;
const API_CACHE = `api-cache-v${SERVICE_WORKER_VERSION}`;
// Files to cache on install
const STATIC_FILES = [
'/',
'/index.html',
'/manifest.json',
'/styles/main.css',
'/scripts/main.js',
'/images/icon-192.png',
'/images/icon-512.png',
'/offline.html',
];
// Install event
self.addEventListener('install', (event) => {
console.log('Service Worker: Installing version', SERVICE_WORKER_VERSION);
event.waitUntil(
caches
.open(STATIC_CACHE)
.then((cache) => {
console.log('Service Worker: Caching static files');
return cache.addAll(STATIC_FILES);
})
.then(() => {
console.log('Service Worker: Static files cached');
return self.skipWaiting();
})
.catch((error) => {
console.error('Service Worker: Install failed', error);
})
);
});
// Activate event
self.addEventListener('activate', (event) => {
console.log('Service Worker: Activating version', SERVICE_WORKER_VERSION);
event.waitUntil(
Promise.all([
// Clean up old caches
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
if (
cacheName !== STATIC_CACHE &&
cacheName !== DYNAMIC_CACHE &&
cacheName !== API_CACHE
) {
console.log('Service Worker: Deleting old cache:', cacheName);
return caches.delete(cacheName);
}
})
);
}),
// Claim all clients
self.clients.claim(),
])
);
});
// Fetch event with advanced routing
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// Skip non-GET requests
if (request.method !== 'GET') {
return;
}
// Handle different types of requests
if (url.pathname.startsWith('/api/')) {
// API requests - Network first with cache fallback
event.respondWith(handleAPIRequest(request));
} else if (
url.pathname.match(/\.(css|js|png|jpg|jpeg|gif|svg|woff|woff2|ico)$/)
) {
// Static assets - Cache first
event.respondWith(handleStaticAssets(request));
} else if (url.pathname === '/' || url.pathname.endsWith('.html')) {
// HTML pages - Stale while revalidate
event.respondWith(handleHTMLPages(request));
} else {
// Everything else - Network first
event.respondWith(handleOtherRequests(request));
}
});
// API request handler
async function handleAPIRequest(request) {
const cache = await caches.open(API_CACHE);
try {
// Try network first
const networkResponse = await fetch(request);
if (networkResponse.ok) {
// Cache successful responses with timestamp
const responseClone = networkResponse.clone();
const response = new Response(responseClone.body, {
status: responseClone.status,
statusText: responseClone.statusText,
headers: {
...Object.fromEntries(responseClone.headers.entries()),
'sw-cache-timestamp': Date.now().toString(),
},
});
cache.put(request, response);
}
return networkResponse;
} catch (error) {
console.warn('Network request failed, trying cache:', error);
const cachedResponse = await cache.match(request);
if (cachedResponse) {
// Check if cache is expired (5 minutes for API)
const cacheTimestamp = cachedResponse.headers.get('sw-cache-timestamp');
const isExpired =
cacheTimestamp && Date.now() - parseInt(cacheTimestamp) > 5 * 60 * 1000;
if (!isExpired) {
return cachedResponse;
}
}
// Return offline response
return new Response(
JSON.stringify({ error: 'Offline', message: 'Unable to fetch data' }),
{
status: 503,
statusText: 'Service Unavailable',
headers: { 'Content-Type': 'application/json' },
}
);
}
}
// Static assets handler
async function handleStaticAssets(request) {
const cache = await caches.open(STATIC_CACHE);
const cachedResponse = await cache.match(request);
if (cachedResponse) {
return cachedResponse;
}
try {
const networkResponse = await fetch(request);
if (networkResponse.ok) {
cache.put(request, networkResponse.clone());
}
return networkResponse;
} catch (error) {
console.error('Failed to fetch static asset:', error);
// Return a fallback for failed requests
if (request.url.match(/\.(png|jpg|jpeg|gif|svg)$/)) {
return new Response(
'<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200"><rect width="200" height="200" fill="#f0f0f0"/><text x="50%" y="50%" text-anchor="middle" fill="#666">Image not available</text></svg>',
{ headers: { 'Content-Type': 'image/svg+xml' } }
);
}
throw error;
}
}
// HTML pages handler
async function handleHTMLPages(request) {
const cache = await caches.open(DYNAMIC_CACHE);
const cachedResponse = await cache.match(request);
// Start network request in background
const networkPromise = fetch(request)
.then((response) => {
if (response.ok) {
cache.put(request, response.clone());
}
return response;
})
.catch((error) => {
console.warn('Network request failed:', error);
return null;
});
// Return cached response immediately if available
if (cachedResponse) {
return cachedResponse;
}
// Wait for network response
const networkResponse = await networkPromise;
if (networkResponse) {
return networkResponse;
}
// Return offline page
const offlineResponse = await cache.match('/offline.html');
return offlineResponse || new Response('Offline', { status: 503 });
}
// Other requests handler
async function handleOtherRequests(request) {
try {
return await fetch(request);
} catch (error) {
console.error('Request failed:', error);
const cache = await caches.open(DYNAMIC_CACHE);
const cachedResponse = await cache.match(request);
return cachedResponse || new Response('Offline', { status: 503 });
}
}
// Background sync
self.addEventListener('sync', (event) => {
console.log('Service Worker: Background sync triggered for tag:', event.tag);
if (event.tag === 'background-sync') {
event.waitUntil(doBackgroundSync());
}
});
async function doBackgroundSync() {
try {
// Get pending requests from IndexedDB
const db = await openIndexedDB();
const pendingRequests = await getPendingRequests(db);
let syncedCount = 0;
for (const requestData of pendingRequests) {
try {
const response = await fetch(requestData.url, {
method: requestData.method,
body: requestData.body,
headers: requestData.headers,
});
if (response.ok) {
await removePendingRequest(db, requestData.id);
syncedCount++;
}
} catch (error) {
console.error('Sync failed for request:', requestData.url, error);
}
}
// Notify all clients
const clients = await self.clients.matchAll();
clients.forEach((client) => {
client.postMessage({
type: 'SYNC_COMPLETE',
payload: { synced: syncedCount, total: pendingRequests.length },
});
});
} catch (error) {
console.error('Background sync failed:', error);
}
}
// Push notifications
self.addEventListener('push', (event) => {
console.log('Service Worker: Push received');
let notificationData = {
title: 'PWA Notification',
body: 'You have a new notification',
icon: '/images/icon-192.png',
badge: '/images/badge.png',
vibrate: [100, 50, 100],
data: {
dateOfArrival: Date.now(),
primaryKey: Math.random(),
},
actions: [
{
action: 'explore',
title: 'View',
icon: '/images/view-icon.png',
},
{
action: 'close',
title: 'Close',
icon: '/images/close-icon.png',
},
],
requireInteraction: true,
};
if (event.data) {
try {
const payload = event.data.json();
notificationData = { ...notificationData, ...payload };
} catch (error) {
console.error('Failed to parse push data:', error);
}
}
event.waitUntil(
self.registration.showNotification(notificationData.title, notificationData)
);
});
// Notification click
self.addEventListener('notificationclick', (event) => {
console.log('Service Worker: Notification clicked');
event.notification.close();
if (event.action === 'explore') {
event.waitUntil(clients.openWindow(event.notification.data?.url || '/'));
} else if (event.action === 'close') {
// Just close the notification
} else {
// Default action - open the app
event.waitUntil(clients.openWindow('/'));
}
});
// Message handling
self.addEventListener('message', async (event) => {
console.log('Service Worker: Message received:', event.data);
const { action, data } = event.data;
switch (action) {
case 'skipWaiting':
self.skipWaiting();
break;
case 'getCacheSize':
const cacheSize = await getCacheSize();
event.ports[0].postMessage({ cacheSize });
break;
case 'clearCache':
await clearAllCaches();
event.ports[0].postMessage({ success: true });
break;
case 'updateCache':
await updateStaticCache();
event.ports[0].postMessage({ success: true });
break;
}
});
// Utility functions for IndexedDB operations
async function openIndexedDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open('PWADatabase', 1);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
request.onupgradeneeded = (event) => {
const db = event.target.result;
if (!db.objectStoreNames.contains('pendingSync')) {
const store = db.createObjectStore('pendingSync', {
keyPath: 'id',
autoIncrement: true,
});
store.createIndex('timestamp', 'timestamp', { unique: false });
}
};
});
}
async function getPendingRequests(db) {
return new Promise((resolve, reject) => {
const transaction = db.transaction(['pendingSync'], 'readonly');
const store = transaction.objectStore('pendingSync');
const request = store.getAll();
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
});
}
async function removePendingRequest(db, id) {
return new Promise((resolve, reject) => {
const transaction = db.transaction(['pendingSync'], 'readwrite');
const store = transaction.objectStore('pendingSync');
const request = store.delete(id);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve();
});
}
async function getCacheSize() {
const cacheNames = await caches.keys();
let totalSize = 0;
for (const cacheName of cacheNames) {
const cache = await caches.open(cacheName);
const requests = await cache.keys();
for (const request of requests) {
const response = await cache.match(request);
if (response) {
const blob = await response.blob();
totalSize += blob.size;
}
}
}
return totalSize;
}
async function clearAllCaches() {
const cacheNames = await caches.keys();
return Promise.all(cacheNames.map((cacheName) => caches.delete(cacheName)));
}
async function updateStaticCache() {
const cache = await caches.open(STATIC_CACHE);
return cache.addAll(STATIC_FILES);
}
console.log('Service Worker: Script loaded, version', SERVICE_WORKER_VERSION);
Conclusion
Progressive Web Apps represent the future of web applications, combining the best of web and native app experiences. Through service workers, advanced caching strategies, push notifications, and offline functionality, PWAs deliver app-like experiences that users expect from modern applications. The key to successful PWA development is implementing robust caching strategies, providing seamless offline experiences, and optimizing for performance across different network conditions.
When building PWAs with JavaScript, focus on progressive enhancement, ensuring your app works well even when advanced features aren't supported. Implement proper analytics to understand user engagement, provide clear installation prompts, and maintain your service workers with proper update mechanisms. PWAs can significantly improve user engagement, reduce bounce rates, and provide native app-like experiences without the friction of app store installations.