Progressive Web Applications (PWA)
Build modern, reliable, and engaging Progressive Web Applications with offline capabilities
Key Facts
- Category
- Web Technologies
- Items
- 4
- Format Families
- json
Sample Overview
Build modern, reliable, and engaging Progressive Web Applications with offline capabilities This sample set belongs to Web Technologies and can be used to test related workflows inside Elysia Tools.
💻 PWA Manifest and Installation json
🟢 simple
⭐⭐
Create a PWA manifest and enable app installation
⏱️ 15 min
🏷️ pwa, manifest, installation, app shell
Prerequisites:
HTML, Web development basics
{
"manifest.json": {
"name": "My Progressive Web App",
"short_name": "MyPWA",
"description": "A modern Progressive Web Application",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#3367D6",
"orientation": "portrait-primary",
"scope": "/",
"lang": "en",
"dir": "ltr",
"categories": ["productivity", "utilities"],
"screenshots": [
{
"src": "/screenshots/desktop-1.png",
"sizes": "1280x720",
"type": "image/png",
"form_factor": "wide",
"label": "Desktop view of the app"
},
{
"src": "/screenshots/mobile-1.png",
"sizes": "375x667",
"type": "image/png",
"form_factor": "narrow",
"label": "Mobile view of the app"
}
],
"icons": [
{
"src": "/icons/icon-72x72.png",
"sizes": "72x72",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icons/icon-96x96.png",
"sizes": "96x96",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icons/icon-128x128.png",
"sizes": "128x128",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icons/icon-144x144.png",
"sizes": "144x144",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icons/icon-152x152.png",
"sizes": "152x152",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icons/icon-384x384.png",
"sizes": "384x384",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable any"
}
],
"shortcuts": [
{
"name": "New Task",
"short_name": "New Task",
"description": "Create a new task quickly",
"url": "/new-task",
"icons": [
{
"src": "/icons/shortcut-new.png",
"sizes": "96x96"
}
]
},
{
"name": "Search",
"short_name": "Search",
"description": "Search your tasks",
"url": "/search",
"icons": [
{
"src": "/icons/shortcut-search.png",
"sizes": "96x96"
}
]
}
],
"related_applications": [
{
"platform": "play",
"url": "https://play.google.com/store/apps/details?id=com.example.app",
"id": "com.example.app"
}
],
"prefer_related_applications": false,
"share_target": {
"action": "/share-handler",
"method": "POST",
"enctype": "multipart/form-data",
"params": {
"title": "title",
"text": "text",
"url": "url",
"files": [
{
"name": "images",
"accept": ["image/*"]
}
]
}
},
"protocol_handlers": [
{
"protocol": "web+pwa",
"url": "/handle-protocol?type=%s"
}
]
}
}
<!-- HTML head section -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>My Progressive Web App</title>
<!-- Theme color -->
<meta name="theme-color" content="#3367D6">
<!-- Apple specific meta tags -->
<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">
<!-- Apple icons -->
<link rel="apple-touch-icon" sizes="72x72" href="/icons/icon-72x72.png">
<link rel="apple-touch-icon" sizes="96x96" href="/icons/icon-96x96.png">
<link rel="apple-touch-icon" sizes="128x128" href="/icons/icon-128x128.png">
<link rel="apple-touch-icon" sizes="144x144" href="/icons/icon-144x144.png">
<link rel="apple-touch-icon" sizes="152x152" href="/icons/icon-152x152.png">
<link rel="apple-touch-icon" sizes="192x192" href="/icons/icon-192x192.png">
<link rel="apple-touch-icon" sizes="384x384" href="/icons/icon-384x384.png">
<link rel="apple-touch-icon" sizes="512x512" href="/icons/icon-512x512.png">
<!-- PWA manifest -->
<link rel="manifest" href="/manifest.json">
<!-- Splash screen images -->
<link rel="apple-touch-startup-image"
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2)"
href="/screenshots/apple-launch-828x1792.png">
<link rel="apple-touch-startup-image"
media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3)"
href="/screenshots/apple-launch-1125x2436.png">
<!-- Add more device-specific splash screens -->
<!-- Styles and scripts -->
<link rel="stylesheet" href="/styles/main.css">
</head>
<body>
<div id="app">
<header>
<h1>My Progressive Web App</h1>
<div id="install-prompt" style="display: none;">
<button id="install-button">Install App</button>
</div>
</header>
<main>
<!-- App content here -->
</main>
</div>
<script src="/scripts/app.js"></script>
</body>
</html>
💻 PWA Install Prompt Management javascript
🟡 intermediate
⭐⭐⭐
Handle app installation prompts and user experience
⏱️ 20 min
🏷️ pwa, installation, user experience, ios
Prerequisites:
JavaScript, PWA basics, Event handling
// PWA Install Prompt Management
class PWAInstallManager {
constructor() {
this.deferredPrompt = null;
this.installButton = document.getElementById('install-button');
this.installPrompt = document.getElementById('install-prompt');
this.init();
}
init() {
// Listen for beforeinstallprompt event
window.addEventListener('beforeinstallprompt', (e) => {
// Prevent the mini-infobar from appearing on mobile
e.preventDefault();
// Stash the event so it can be triggered later
this.deferredPrompt = e;
// Show install button
this.showInstallPrompt();
console.log('beforeinstallprompt event fired');
});
// Listen for app installed event
window.addEventListener('appinstalled', () => {
// Hide install button
this.hideInstallPrompt();
// Clear the deferred prompt
this.deferredPrompt = null;
// Show success message
this.showInstallSuccess();
console.log('PWA was installed');
// Analytics: track installation
this.trackInstallEvent();
});
// Check if app is already installed
if (this.isAppInstalled()) {
this.hideInstallPrompt();
this.showAlreadyInstalled();
}
// Handle install button click
if (this.installButton) {
this.installButton.addEventListener('click', () => {
this.installPWA();
});
}
// Check for iOS devices (no beforeinstallprompt event)
if (this.isIOS()) {
this.showIOSInstallInstructions();
}
// Listen for standalone mode changes
window.addEventListener('appmodechange', () => {
this.handleAppModeChange();
});
// Periodically check installability
this.checkInstallability();
}
showInstallPrompt() {
if (this.installPrompt) {
// Don't show if user has dismissed before
const dismissed = localStorage.getItem('installPromptDismissed');
if (!dismissed) {
this.installPrompt.style.display = 'block';
}
}
}
hideInstallPrompt() {
if (this.installPrompt) {
this.installPrompt.style.display = 'none';
}
}
async installPWA() {
if (!this.deferredPrompt) {
console.log('No deferred prompt available');
return;
}
try {
// Show the install prompt
this.deferredPrompt.prompt();
// Wait for the user to respond to the prompt
const { outcome } = await this.deferredPrompt.userChoice;
console.log('User response to the install prompt:', outcome);
// We've used the prompt, and can't use it again, throw it away
this.deferredPrompt = null;
if (outcome === 'accepted') {
console.log('User accepted the install prompt');
// Analytics: track acceptance
this.trackInstallEvent('accepted');
} else {
console.log('User dismissed the install prompt');
// Analytics: track dismissal
this.trackInstallEvent('dismissed');
// Hide for a while
this.dismissInstallPrompt();
}
} catch (error) {
console.error('Error during PWA installation:', error);
}
}
dismissInstallPrompt() {
this.hideInstallPrompt();
localStorage.setItem('installPromptDismissed', 'true');
// Show again after 7 days
setTimeout(() => {
localStorage.removeItem('installPromptDismissed');
if (this.deferredPrompt) {
this.showInstallPrompt();
}
}, 7 * 24 * 60 * 60 * 1000);
}
showInstallSuccess() {
this.showNotification('App installed successfully!', 'success');
}
showAlreadyInstalled() {
console.log('App is already installed');
}
showIOSInstallInstructions() {
// iOS doesn't support beforeinstallprompt
// Show custom instructions for Safari
const userAgent = navigator.userAgent.toLowerCase();
if (userAgent.includes('safari') && !userAgent.includes('chrome')) {
const iosPrompt = document.createElement('div');
iosPrompt.className = 'ios-install-prompt';
iosPrompt.innerHTML = `
<div class="ios-install-content">
<h3>Install this App</h3>
<p>To install this app on your iOS device:</p>
<ol>
<li>Tap the Share button <span class="share-icon">⎋</span> in Safari</li>
<li>Scroll down and tap "Add to Home Screen"</li>
<li>Tap "Add" to confirm</li>
</ol>
<button class="close-btn">Got it!</button>
</div>
`;
document.body.appendChild(iosPrompt);
// Show after a delay
setTimeout(() => {
iosPrompt.classList.add('show');
}, 3000);
// Handle close button
iosPrompt.querySelector('.close-btn').addEventListener('click', () => {
iosPrompt.classList.remove('show');
setTimeout(() => {
document.body.removeChild(iosPrompt);
}, 300);
});
// Auto-hide after 10 seconds
setTimeout(() => {
if (iosPrompt.parentNode) {
iosPrompt.classList.remove('show');
setTimeout(() => {
if (iosPrompt.parentNode) {
document.body.removeChild(iosPrompt);
}
}, 300);
}
}, 10000);
}
}
isAppInstalled() {
// Check if running in standalone mode
return window.matchMedia('(display-mode: standalone)').matches ||
window.navigator.standalone === true ||
document.referrer.includes('android-app://');
}
isIOS() {
return /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
}
handleAppModeChange() {
if (this.isAppInstalled()) {
// App is now running in standalone mode
document.body.classList.add('standalone-mode');
// Enable app-specific features
this.enableStandaloneFeatures();
} else {
document.body.classList.remove('standalone-mode');
}
}
enableStandaloneFeatures() {
// Enable features only available in standalone mode
console.log('Enabling standalone mode features');
// Example: Show back button navigation
this.addBackButton();
// Example: Customize status bar
this.customizeStatusBar();
}
addBackButton() {
if (document.referrer && document.referrer !== window.location.href) {
const backButton = document.createElement('button');
backButton.className = 'back-button';
backButton.innerHTML = '←';
backButton.addEventListener('click', () => {
window.history.back();
});
document.body.insertBefore(backButton, document.body.firstChild);
}
}
customizeStatusBar() {
// This works on iOS Safari
const metaTheme = document.querySelector('meta[name="theme-color"]');
if (metaTheme && this.isIOS()) {
metaTheme.content = '#000000'; // Dark status bar for iOS
}
}
async checkInstallability() {
// Check if PWA meets installability criteria
const isInstallable = await this.isPWAInstallable();
if (!isInstallable) {
console.warn('PWA may not be installable due to missing requirements');
this.showInstallabilityIssues();
}
}
async isPWAInstallable() {
try {
// Check if served over HTTPS
if (location.protocol !== 'https:' && location.hostname !== 'localhost') {
return false;
}
// Check for service worker
if (!('serviceWorker' in navigator)) {
return false;
}
// Check for manifest
const manifest = await fetch('/manifest.json').then(r => r.json()).catch(() => null);
if (!manifest) {
return false;
}
// Check for icons
if (!manifest.icons || manifest.icons.length === 0) {
return false;
}
// Check for suitable icon size
const hasLargeIcon = manifest.icons.some(icon =>
icon.sizes && icon.sizes.split('x').some(size => parseInt(size) >= 192)
);
if (!hasLargeIcon) {
return false;
}
// Check if service worker is registered
const registration = await navigator.serviceWorker.ready;
if (!registration) {
return false;
}
return true;
} catch (error) {
console.error('Error checking installability:', error);
return false;
}
}
showInstallabilityIssues() {
const issues = [];
if (location.protocol !== 'https:' && location.hostname !== 'localhost') {
issues.push('Serve over HTTPS');
}
if (!('serviceWorker' in navigator)) {
issues.push('Service Worker support');
}
// Add more checks as needed
if (issues.length > 0) {
console.warn('PWA installability issues:', issues);
}
}
showNotification(message, type = 'info') {
const notification = document.createElement('div');
notification.className = `pwa-notification pwa-notification--${type}`;
notification.textContent = message;
document.body.appendChild(notification);
setTimeout(() => {
notification.classList.add('show');
}, 100);
setTimeout(() => {
notification.classList.remove('show');
setTimeout(() => {
if (notification.parentNode) {
document.body.removeChild(notification);
}
}, 300);
}, 3000);
}
trackInstallEvent(action = 'prompt') {
// Analytics tracking for install events
if (window.gtag) {
gtag('event', 'pwa_install', {
'event_category': 'PWA',
'event_label': action,
'value': 1
});
}
}
}
// Usage
document.addEventListener('DOMContentLoaded', () => {
new PWAInstallManager();
});
💻 Offline-First PWA Architecture javascript
🟡 intermediate
⭐⭐⭐⭐
Build a robust offline-first Progressive Web Application
⏱️ 35 min
🏷️ offline-first, pwa, indexeddb, sync, architecture
Prerequisites:
JavaScript, IndexedDB, Service Workers, PWA concepts
// Offline-First PWA Architecture
class OfflineFirstPWA {
constructor() {
this.db = null;
this.onlineStatus = navigator.onLine;
this.pendingOperations = new Map();
this.init();
}
async init() {
// Initialize IndexedDB
await this.initDatabase();
// Setup network status listeners
this.setupNetworkListeners();
// Register service worker
await this.registerServiceWorker();
// Setup sync manager
this.setupBackgroundSync();
// Load cached data
await this.loadOfflineData();
console.log('Offline-First PWA initialized');
}
async initDatabase() {
return new Promise((resolve, reject) => {
const request = indexedDB.open('offline-pwa-db', 1);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
this.db = request.result;
resolve();
};
request.onupgradeneeded = (event) => {
const db = event.target.result;
// Store for cached API responses
if (!db.objectStoreNames.contains('api-cache')) {
db.createObjectStore('api-cache', { keyPath: 'url' });
}
// Store for user data
if (!db.objectStoreNames.contains('user-data')) {
db.createObjectStore('user-data', { keyPath: 'id', autoIncrement: true });
}
// Store for pending operations
if (!db.objectStoreNames.contains('pending-operations')) {
db.createObjectStore('pending-operations', { keyPath: 'id', autoIncrement: true });
}
// Store for offline files
if (!db.objectStoreNames.contains('offline-files')) {
db.createObjectStore('offline-files', { keyPath: 'url' });
}
};
});
}
setupNetworkListeners() {
window.addEventListener('online', () => {
this.onlineStatus = true;
this.handleOnlineStatus();
console.log('App is online');
});
window.addEventListener('offline', () => {
this.onlineStatus = false;
this.handleOfflineStatus();
console.log('App is offline');
});
// Update UI based on current status
this.updateOnlineStatusUI();
}
handleOnlineStatus() {
// Show online indicator
this.showStatus('Connected', 'online');
// Process pending operations
this.processPendingOperations();
// Sync data with server
this.syncWithServer();
// Refresh cached data
this.refreshStaleData();
}
handleOfflineStatus() {
// Show offline indicator
this.showStatus('Offline - Limited functionality', 'offline');
// Switch to offline mode
this.enableOfflineMode();
// Warn about limitations
this.warnOfflineLimitations();
}
async registerServiceWorker() {
if ('serviceWorker' in navigator) {
try {
const registration = await navigator.serviceWorker.register('/offline-pwa-sw.js');
console.log('Service Worker registered:', registration);
// Listen for messages from service worker
navigator.serviceWorker.addEventListener('message', (event) => {
this.handleServiceWorkerMessage(event);
});
return registration;
} catch (error) {
console.error('Service Worker registration failed:', error);
}
}
}
setupBackgroundSync() {
if ('serviceWorker' in navigator &&
typeof ServiceWorkerRegistration !== 'undefined' &&
'sync' in ServiceWorkerRegistration.prototype) {
navigator.serviceWorker.ready.then((registration) => {
registration.sync.register('offline-data-sync');
});
}
}
async loadOfflineData() {
try {
// Load essential user data from IndexedDB
const userData = await this.getAllUserData();
this.renderUserData(userData);
// Load cached API responses
const cachedData = await this.getCachedData();
this.renderCachedData(cachedData);
} catch (error) {
console.error('Error loading offline data:', error);
}
}
// API methods with offline support
async apiRequest(url, options = {}) {
const cacheKey = url + JSON.stringify(options);
if (!this.onlineStatus) {
// Offline: return cached data or create pending operation
const cachedResponse = await this.getCachedResponse(url);
if (cachedResponse) {
return cachedResponse;
}
if (options.method && options.method !== 'GET') {
// Create pending operation for non-GET requests
return this.createPendingOperation(url, options);
}
throw new Error('No cached data available');
}
try {
// Online: make actual request
const response = await fetch(url, options);
if (response.ok) {
const data = await response.json();
// Cache the response
await this.cacheResponse(url, data);
return data;
}
throw new Error('API request failed');
} catch (error) {
console.error('API request failed:', error);
// Fallback to cached data
const cachedResponse = await this.getCachedResponse(url);
if (cachedResponse) {
this.showStatus('Using cached data', 'warning');
return cachedResponse;
}
throw error;
}
}
async cacheResponse(url, data) {
const transaction = this.db.transaction(['api-cache'], 'readwrite');
const store = transaction.objectStore('api-cache');
const cacheEntry = {
url: url,
data: data,
timestamp: Date.now(),
ttl: Date.now() + (5 * 60 * 1000) // 5 minutes TTL
};
await store.put(cacheEntry);
}
async getCachedResponse(url) {
const transaction = this.db.transaction(['api-cache'], 'readonly');
const store = transaction.objectStore('api-cache');
const cacheEntry = await store.get(url);
if (cacheEntry && Date.now() < cacheEntry.ttl) {
return cacheEntry.data;
}
return null;
}
async createPendingOperation(url, options) {
const operation = {
id: Date.now().toString(),
url: url,
options: options,
timestamp: Date.now(),
retries: 0
};
const transaction = this.db.transaction(['pending-operations'], 'readwrite');
const store = transaction.objectStore('pending-operations');
await store.add(operation);
this.showStatus('Operation queued for sync', 'info');
return { success: true, message: 'Operation queued for sync', operationId: operation.id };
}
async processPendingOperations() {
const transaction = this.db.transaction(['pending-operations'], 'readonly');
const store = transaction.objectStore('pending-operations');
const operations = await store.getAll();
for (const operation of operations) {
try {
await this.retryOperation(operation);
await this.removePendingOperation(operation.id);
} catch (error) {
console.error('Failed to process operation:', operation.id, error);
}
}
}
async retryOperation(operation) {
const maxRetries = 3;
if (operation.retries >= maxRetries) {
throw new Error('Max retries exceeded');
}
try {
const response = await fetch(operation.url, operation.options);
if (!response.ok) {
throw new Error('Request failed');
}
// Operation successful
console.log('Operation synced successfully:', operation.id);
} catch (error) {
// Increment retry count
operation.retries++;
// Update operation in database
const transaction = this.db.transaction(['pending-operations'], 'readwrite');
const store = transaction.objectStore('pending-operations');
await store.put(operation);
throw error;
}
}
async removePendingOperation(id) {
const transaction = this.db.transaction(['pending-operations'], 'readwrite');
const store = transaction.objectStore('pending-operations');
await store.delete(id);
}
// File handling for offline support
async cacheFile(url, file) {
const fileData = {
url: url,
file: file,
timestamp: Date.now()
};
const transaction = this.db.transaction(['offline-files'], 'readwrite');
const store = transaction.objectStore('offline-files');
await store.put(fileData);
}
async getCachedFile(url) {
const transaction = this.db.transaction(['offline-files'], 'readonly');
const store = transaction.objectStore('pending-operations');
const fileData = await store.get(url);
return fileData ? fileData.file : null;
}
// UI Methods
updateOnlineStatusUI() {
const statusElement = document.getElementById('online-status');
if (statusElement) {
statusElement.className = this.onlineStatus ? 'online' : 'offline';
statusElement.textContent = this.onlineStatus ? 'Online' : 'Offline';
}
}
showStatus(message, type = 'info') {
const statusElement = document.getElementById('status-message');
if (statusElement) {
statusElement.textContent = message;
statusElement.className = `status status--${type}`;
setTimeout(() => {
statusElement.textContent = '';
statusElement.className = 'status';
}, 3000);
}
}
showNotification(title, options = {}) {
if ('Notification' in window && Notification.permission === 'granted') {
new Notification(title, {
body: options.body || '',
icon: '/icons/icon-192x192.png',
badge: '/icons/badge-72x72.png',
tag: options.tag || 'default',
requireInteraction: options.requireInteraction || false,
actions: options.actions || []
});
}
}
enableOfflineMode() {
// Enable offline-specific features
document.body.classList.add('offline-mode');
// Disable online-only features
const onlineOnlyElements = document.querySelectorAll('[data-online-only]');
onlineOnlyElements.forEach(element => {
element.style.display = 'none';
});
// Show offline indicators
const offlineIndicators = document.querySelectorAll('[data-offline-indicator]');
offlineIndicators.forEach(element => {
element.style.display = 'block';
});
}
warnOfflineLimitations() {
this.showNotification('You are currently offline', {
body: 'Some features may be unavailable until you reconnect.',
requireInteraction: false,
tag: 'offline-warning'
});
}
// Data synchronization
async syncWithServer() {
try {
// Get local changes
const localChanges = await this.getLocalChanges();
// Sync with server
const syncResult = await this.sendChangesToServer(localChanges);
if (syncResult.success) {
// Clear local changes after successful sync
await this.clearLocalChanges();
this.showStatus('Data synchronized successfully', 'success');
}
} catch (error) {
console.error('Sync failed:', error);
this.showStatus('Sync failed, will retry later', 'warning');
}
}
async refreshStaleData() {
// Refresh data that might be stale
const staleData = await this.getStaleCachedData();
for (const item of staleData) {
try {
await this.apiRequest(item.url, { method: 'GET' });
} catch (error) {
console.error('Failed to refresh stale data:', item.url);
}
}
}
handleServiceWorkerMessage(event) {
const { type, data } = event.data;
switch (type) {
case 'cache-updated':
this.handleCacheUpdated(data);
break;
case 'sync-completed':
this.handleSyncCompleted(data);
break;
case 'background-sync-failed':
this.handleBackgroundSyncFailed(data);
break;
default:
console.log('Unknown message from service worker:', type, data);
}
}
handleCacheUpdated(data) {
console.log('Cache updated:', data);
this.showStatus('Content updated', 'success');
}
handleSyncCompleted(data) {
console.log('Background sync completed:', data);
this.showNotification('Sync completed', {
body: 'Your data has been synchronized with the server.'
});
}
handleBackgroundSyncFailed(data) {
console.error('Background sync failed:', data);
this.showStatus('Background sync failed', 'error');
}
}
// Initialize the PWA
document.addEventListener('DOMContentLoaded', () => {
window.pwaApp = new OfflineFirstPWA();
});
// Service Worker file (offline-pwa-sw.js)
const OFFLINE_CACHE = 'offline-cache-v1';
const STATIC_CACHE = 'static-cache-v1';
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(OFFLINE_CACHE)
.then((cache) => cache.addAll([
'/',
'/offline.html',
'/manifest.json',
'/styles/main.css',
'/scripts/app.js'
]))
);
});
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request)
.then((response) => {
return response || fetch(event.request);
})
.catch(() => {
// Return offline page for navigation requests
if (event.request.mode === 'navigate') {
return caches.match('/offline.html');
}
})
);
});
self.addEventListener('sync', (event) => {
if (event.tag === 'offline-data-sync') {
event.waitUntil(
// Process offline data sync
processOfflineDataSync()
);
}
});
async function processOfflineDataSync() {
// Implementation for background sync
console.log('Processing offline data sync');
}
💻 PWA App Shell Architecture javascript
🔴 complex
⭐⭐⭐⭐⭐
Implement the app shell architecture for instant loading and reliable navigation
⏱️ 40 min
🏷️ app shell, pwa, architecture, routing, performance
Prerequisites:
JavaScript, PWA concepts, Web Components, Service Workers
// PWA App Shell Architecture
class PWAShell {
constructor() {
this.currentRoute = null;
this.shellElement = null;
this.contentElement = null;
this.routes = new Map();
this.loadingIndicator = null;
this.init();
}
init() {
// Cache DOM elements
this.shellElement = document.getElementById('app-shell');
this.contentElement = document.getElementById('app-content');
this.loadingIndicator = document.getElementById('loading-indicator');
// Setup routing
this.setupRouting();
// Setup navigation
this.setupNavigation();
// Setup service worker communication
this.setupServiceWorkerComms();
// Handle initial load
this.handleInitialLoad();
// Setup prefetching
this.setupPrefetching();
// Setup back/forward navigation
this.setupHistoryNavigation();
console.log('PWA App Shell initialized');
}
setupRouting() {
// Define app routes
this.routes.set('/', {
component: 'HomePage',
prefetch: true,
cache: true,
preload: ['/api/user-data', '/api/recent-items']
});
this.routes.set('/dashboard', {
component: 'DashboardPage',
prefetch: true,
cache: true,
preload: ['/api/dashboard-stats', '/api/notifications']
});
this.routes.set('/profile', {
component: 'ProfilePage',
prefetch: false,
cache: true,
preload: ['/api/user-profile']
});
this.routes.set('/settings', {
component: 'SettingsPage',
prefetch: false,
cache: true,
preload: ['/api/settings']
});
this.routes.set('/offline', {
component: 'OfflinePage',
prefetch: false,
cache: true
});
// Listen for route changes
window.addEventListener('popstate', (event) => {
this.navigate(window.location.pathname, false);
});
// Intercept link clicks
document.addEventListener('click', (event) => {
const link = event.target.closest('a');
if (link && this.shouldInterceptLink(link)) {
event.preventDefault();
this.navigate(link.pathname);
}
});
}
shouldInterceptLink(link) {
const href = link.getAttribute('href');
// Only intercept internal links
if (!href || href.startsWith('http') || href.startsWith('#')) {
return false;
}
// Don't intercept if modifier keys are pressed
if (event.ctrlKey || event.metaKey || event.shiftKey) {
return false;
}
// Don't intercept if target is specified
if (link.target) {
return false;
}
return true;
}
async navigate(path, addToHistory = true) {
// Don't navigate if already on this route
if (this.currentRoute === path) {
return;
}
try {
this.showLoading(true);
// Find route configuration
const route = this.routes.get(path);
if (!route) {
// Try to find dynamic route
const dynamicRoute = this.findDynamicRoute(path);
if (dynamicRoute) {
await this.loadRoute(dynamicRoute, addToHistory, path);
} else {
// Fallback to 404 or offline page
await this.load404Page();
}
return;
}
await this.loadRoute(route, addToHistory, path);
} catch (error) {
console.error('Navigation error:', error);
await this.handleNavigationError(path);
} finally {
this.showLoading(false);
}
}
async loadRoute(route, addToHistory, path) {
// Update URL
if (addToHistory) {
history.pushState({ path: path }, '', path);
}
// Preload necessary data
if (route.preload) {
await this.preloadData(route.preload);
}
// Load and render component
const component = await this.loadComponent(route.component);
await this.renderComponent(component);
// Cache route content
if (route.cache) {
await this.cacheRouteContent(path, component);
}
// Update current route
this.currentRoute = path;
// Update navigation state
this.updateNavigationState(path);
// Trigger route change event
this.dispatchRouteChangeEvent(path);
// Track page view for analytics
this.trackPageView(path);
}
async loadComponent(componentName) {
try {
// Check if component is already loaded
if (window[componentName]) {
return window[componentName];
}
// Load component script
const module = await import(`/components/${componentName}.js`);
window[componentName] = module.default;
return module.default;
} catch (error) {
console.error('Failed to load component:', componentName, error);
// Fallback to simple content
return {
render: () => '<div class="error">Failed to load page content</div>'
};
}
}
async renderComponent(component) {
if (typeof component.render === 'function') {
const html = await component.render();
// Animate content change
await this.animateContentChange(() => {
this.contentElement.innerHTML = html;
// Initialize component if needed
if (component.init) {
component.init();
}
});
} else {
throw new Error('Component does not have a render method');
}
}
async animateContentChange(callback) {
// Fade out current content
this.contentElement.classList.add('fade-out');
// Wait for animation
await new Promise(resolve => {
setTimeout(resolve, 200);
});
// Update content
callback();
// Fade in new content
this.contentElement.classList.remove('fade-out');
this.contentElement.classList.add('fade-in');
// Wait for animation
await new Promise(resolve => {
setTimeout(resolve => 200);
});
this.contentElement.classList.remove('fade-in');
}
async preloadData(urls) {
const promises = urls.map(url => this.preloadDataUrl(url));
try {
await Promise.allSettled(promises);
} catch (error) {
console.error('Error preloading data:', error);
}
}
async preloadDataUrl(url) {
try {
// Check cache first
const cachedResponse = await this.getCachedData(url);
if (cachedResponse) {
return cachedResponse;
}
// Fetch and cache
const response = await fetch(url);
const data = await response.json();
await this.cacheData(url, data);
return data;
} catch (error) {
console.error('Error preloading data URL:', url, error);
}
}
async cacheData(url, data) {
try {
const cache = await caches.open('api-data-cache-v1');
const response = new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' }
});
await cache.put(url, response);
} catch (error) {
console.error('Error caching data:', error);
}
}
async getCachedData(url) {
try {
const cache = await caches.open('api-data-cache-v1');
const response = await cache.match(url);
if (response) {
return await response.json();
}
return null;
} catch (error) {
console.error('Error getting cached data:', error);
return null;
}
}
setupNavigation() {
const navItems = document.querySelectorAll('[data-route]');
navItems.forEach(item => {
const route = item.getAttribute('data-route');
// Set initial state
if (window.location.pathname === route) {
item.classList.add('active');
}
// Add click handler
item.addEventListener('click', (event) => {
event.preventDefault();
this.navigate(route);
});
});
// Setup mobile navigation
this.setupMobileNavigation();
}
setupMobileNavigation() {
const menuToggle = document.getElementById('menu-toggle');
const mobileNav = document.getElementById('mobile-navigation');
if (menuToggle && mobileNav) {
menuToggle.addEventListener('click', () => {
mobileNav.classList.toggle('open');
document.body.classList.toggle('nav-open');
});
// Close nav when clicking outside
document.addEventListener('click', (event) => {
if (!mobileNav.contains(event.target) && !menuToggle.contains(event.target)) {
mobileNav.classList.remove('open');
document.body.classList.remove('nav-open');
}
});
}
}
updateNavigationState(path) {
// Update active navigation items
const navItems = document.querySelectorAll('[data-route]');
navItems.forEach(item => {
const itemRoute = item.getAttribute('data-route');
if (path === itemRoute) {
item.classList.add('active');
} else {
item.classList.remove('active');
}
});
// Update page title
const route = this.routes.get(path) || this.findDynamicRoute(path);
if (route && route.title) {
document.title = route.title;
}
// Update meta description
if (route && route.description) {
const metaDescription = document.querySelector('meta[name="description"]');
if (metaDescription) {
metaDescription.content = route.description;
}
}
}
setupPrefetching() {
// Prefetch resources on hover
document.addEventListener('mouseover', (event) => {
const link = event.target.closest('a[data-route]');
if (link) {
const route = link.getAttribute('data-route');
const routeConfig = this.routes.get(route);
if (routeConfig && routeConfig.prefetch && !this.isRoutePrefetched(route)) {
this.prefetchRoute(routeConfig);
this.markRouteAsPrefetched(route);
}
}
});
// Prefetch resources on page load for critical routes
this.prefetchCriticalRoutes();
}
async prefetchRoute(route) {
try {
// Prefetch component
await import(`/components/${route.component}.js`);
// Prefetch data
if (route.preload) {
await this.preloadData(route.preload);
}
} catch (error) {
console.error('Error prefetching route:', error);
}
}
prefetchCriticalRoutes() {
// Prefetch dashboard and other critical routes after initial load
setTimeout(() => {
this.routes.forEach((route, path) => {
if (route.prefetch && path !== this.currentRoute) {
this.prefetchRoute(route);
}
});
}, 2000);
}
isRoutePrefetched(route) {
return localStorage.getItem('prefetched-' + route) === 'true';
}
markRouteAsPrefetched(route) {
localStorage.setItem('prefetched-' + route, 'true');
}
setupHistoryNavigation() {
// Handle browser back/forward buttons
window.addEventListener('popstate', (event) => {
if (event.state && event.state.path) {
this.navigate(event.state.path, false);
}
});
// Save initial state
history.replaceState({ path: window.location.pathname }, '');
}
handleInitialLoad() {
// Handle initial page load
this.currentRoute = window.location.pathname;
this.updateNavigationState(this.currentRoute);
// Show content if it exists
if (this.contentElement.children.length === 0) {
this.navigate(this.currentRoute, false);
}
}
async cacheRouteContent(path, component) {
try {
// Cache component for offline use
const content = component.render ? component.render() : '';
const cache = await caches.open('route-cache-v1');
const response = new Response(content, {
headers: { 'Content-Type': 'text/html' }
});
await cache.put(path, response);
} catch (error) {
console.error('Error caching route content:', error);
}
}
findDynamicRoute(path) {
// Simple dynamic route matching
for (const [routePath, config] of this.routes.entries()) {
if (routePath.includes(':')) {
const pattern = new RegExp('^' + routePath.replace(/:[^/]+/g, '[^/]+') + '$');
if (pattern.test(path)) {
return config;
}
}
}
return null;
}
async load404Page() {
const component = await this.loadComponent('Error404Page');
component.path = window.location.pathname;
await this.renderComponent(component);
}
async handleNavigationError(path) {
console.error('Navigation failed for path:', path);
// Try to load offline page
try {
const component = await this.loadComponent('OfflinePage');
await this.renderComponent(component);
} catch (error) {
// Ultimate fallback
this.contentElement.innerHTML = `
<div class="error-page">
<h1>Navigation Error</h1>
<p>Unable to load the requested page.</p>
<button onclick="window.location.reload()">Try Again</button>
</div>
`;
}
}
setupServiceWorkerComms() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('message', (event) => {
const { type, data } = event.data;
switch (type) {
case 'cache-updated':
this.handleCacheUpdate(data);
break;
case 'route-available':
this.handleRouteAvailable(data);
break;
}
});
}
}
handleCacheUpdate(data) {
// Show update notification if current route was updated
if (data.url === window.location.pathname) {
this.showUpdateNotification();
}
}
handleRouteAvailable(data) {
// Prefetch newly available route
if (this.routes.has(data.path)) {
this.prefetchRoute(this.routes.get(data.path));
}
}
showUpdateNotification() {
const notification = document.createElement('div');
notification.className = 'update-notification';
notification.innerHTML = `
<span>Content updated</span>
<button onclick="window.location.reload()">Refresh</button>
`;
document.body.appendChild(notification);
setTimeout(() => {
notification.classList.add('show');
}, 100);
setTimeout(() => {
notification.classList.remove('show');
setTimeout(() => {
if (notification.parentNode) {
document.body.removeChild(notification);
}
}, 300);
}, 5000);
}
showLoading(show) {
if (this.loadingIndicator) {
this.loadingIndicator.style.display = show ? 'block' : 'none';
}
}
dispatchRouteChangeEvent(path) {
const event = new CustomEvent('routechange', {
detail: { path: path }
});
window.dispatchEvent(event);
}
trackPageView(path) {
// Analytics tracking
if (window.gtag) {
gtag('config', 'GA_MEASUREMENT_ID', {
page_path: path
});
}
// Track route change for debugging
console.log('Page view:', path);
}
}
// Component examples
// HomePage.js
export default {
render: async () => `
<div class="home-page">
<h1>Welcome to My PWA</h1>
<p>This is a Progressive Web Application with offline capabilities.</p>
<nav class="quick-actions">
<a href="/dashboard" data-route="/dashboard">Dashboard</a>
<a href="/profile" data-route="/profile">Profile</a>
</nav>
</div>
`,
init: function() {
console.log('Home page initialized');
}
};
// DashboardPage.js
export default {
render: async () => {
const userData = await window.pwaApp.apiRequest('/api/user-data');
return `
<div class="dashboard-page">
<h1>Dashboard</h1>
<div class="stats">
<div class="stat">
<span class="stat-value">${userData.visits || 0}</span>
<span class="stat-label">Visits</span>
</div>
<div class="stat">
<span class="stat-value">${userData.tasks || 0}</span>
<span class="stat-label">Tasks</span>
</div>
</div>
</div>
`;
},
init: function() {
this.refreshInterval = setInterval(() => {
window.pwaApp.apiRequest('/api/refresh-stats');
}, 30000);
},
cleanup: function() {
if (this.refreshInterval) {
clearInterval(this.refreshInterval);
}
}
};
// Initialize the app shell
document.addEventListener('DOMContentLoaded', () => {
window.appShell = new PWAShell();
});