Progressive Web App (PWA) Complete Guide: Membangun Aplikasi Web Modern

0
14

Progressive Web Apps (PWA) telah merevolusi cara kita membangun aplikasi web, menggabungkan keunggulan web dan native applications. Di tahun 2025, PWA menjadi standard untuk aplikasi web modern yang memberikan pengalaman seperti native app namun tetap accessible melalui browser. Artikel ini akan memberikan panduan lengkap untuk membangun PWA yang powerful dan user-friendly.

Apa itu Progressive Web App?

PWA adalah web applications yang menggunakan modern web capabilities untuk menyediakan user experience seperti native mobile app:

1. Core PWA Characteristics
• Progressive: Works untuk semua users, regardless browser choice
• Responsive: Works di semua devices dan form factors
• Connectivity Independent: Works offline atau dengan poor network
• App-like: Feels like native app dengan navigation dan interactions
• Fresh: Always up-to-date dengan background sync
• Safe: Served melalui HTTPS untuk prevent tampering
• Discoverable: Searchable melalui search engines
• Re-engageable: Can send push notifications
• Installable: Can be added to home screen

2. PWA Technology Stack
• Service Workers: Background processes untuk offline capability
• Web App Manifest: JSON file untuk app metadata
• HTTPS: Secure connection requirement
• Responsive Design: Multi-device compatibility
• Application Shell Architecture: Instant loading experience

Service Workers Deep Dive

1. Service Worker Fundamentals
Service worker adalah JavaScript file yang berjalan di background, separate dari web page:

“`javascript
// sw.js – Service Worker Implementation
const CACHE_NAME = ‘pwa-app-v1.0.0’;
const STATIC_CACHE = ‘static-v1’;
const DYNAMIC_CACHE = ‘dynamic-v1’;

// Files to cache immediately
const STATIC_ASSETS = [
‘/’,
‘/index.html’,
‘/css/main.css’,
‘/js/main.js’,
‘/images/logo.png’,
‘/images/icons/icon-192×192.png’,
‘/manifest.json’,
‘https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap’
];

// Install event – cache static assets
self.addEventListener(‘install’, (event) => {
console.log(‘Service Worker: Installing…’);

event.waitUntil(
caches.open(STATIC_CACHE)
.then((cache) => {
console.log(‘Service Worker: Caching static assets’);
return cache.addAll(STATIC_ASSETS);
})
.then(() => self.skipWaiting())
);
});

// Activate event – clean up old caches
self.addEventListener(‘activate’, (event) => {
console.log(‘Service Worker: Activating…’);

event.waitUntil(
caches.keys()
.then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
if (cacheName !== STATIC_CACHE && cacheName !== DYNAMIC_CACHE) {
console.log(‘Service Worker: Deleting old cache:’, cacheName);
return caches.delete(cacheName);
}
})
);
})
.then(() => self.clients.claim())
);
});

// Fetch event – serve from cache with network fallback
self.addEventListener(‘fetch’, (event) => {
const { request } = event;

// Skip non-GET requests
if (request.method !== ‘GET’) {
return;
}

// Strategy 1: Cache First for static assets
if (request.url.includes(‘/css/’) ||
request.url.includes(‘/js/’) ||
request.url.includes(‘/images/’) ||
request.url.includes(‘/fonts/’)) {

event.respondWith(
caches.match(request)
.then((response) => {
return response || fetch(request);
})
);
return;
}

// Strategy 2: Network First for API calls
if (request.url.includes(‘/api/’)) {
event.respondWith(
fetch(request)
.then((response) => {
// Cache successful API responses
if (response.status === 200) {
const responseClone = response.clone();
caches.open(DYNAMIC_CACHE)
.then((cache) => cache.put(request, responseClone));
}
return response;
})
.catch(() => {
// Try to serve from cache if network fails
return caches.match(request);
})
);
return;
}

// Strategy 3: Stale While Revalidate for HTML pages
if (request.headers.get(‘accept’).includes(‘text/html’)) {
event.respondWith(
caches.match(request)
.then((response) => {
const networkFetch = fetch(request)
.then((newResponse) => {
caches.open(DYNAMIC_CACHE)
.then((cache) => cache.put(request, newResponse.clone()));
return newResponse;
});

return response || networkFetch;
})
);
return;
}

// Default: Network request
event.respondWith(fetch(request));
});
“`

2. Advanced Caching Strategies
“`javascript
// Advanced service worker with multiple strategies
class CacheManager {
constructor() {
this.strategies = {
cacheFirst: this.cacheFirst.bind(this),
networkFirst: this.networkFirst.bind(this),
staleWhileRevalidate: this.staleWhileRevalidate.bind(this),
networkOnly: this.networkOnly.bind(this),
cacheOnly: this.cacheOnly.bind(this)
};
}

async cacheFirst(request) {
const cachedResponse = await caches.match(request);
return cachedResponse || fetch(request);
}

async networkFirst(request, cacheName = DYNAMIC_CACHE) {
try {
const networkResponse = await fetch(request);

if (networkResponse.ok) {
const cache = await caches.open(cacheName);
cache.put(request, networkResponse.clone());
}

return networkResponse;
} catch (error) {
return caches.match(request);
}
}

async staleWhileRevalidate(request, cacheName = DYNAMIC_CACHE) {
const cache = await caches.open(cacheName);
const cachedResponse = await cache.match(request);

const networkFetchPromise = fetch(request)
.then((networkResponse) => {
cache.put(request, networkResponse.clone());
return networkResponse;
})
.catch(() => cachedResponse);

return cachedResponse || networkFetchPromise;
}

async networkOnly(request) {
return fetch(request);
}

async cacheOnly(request) {
return caches.match(request);
}

// Dynamic routing based on request type
async handleRequest(request) {
const url = new URL(request.url);

// API calls – Network First
if (url.pathname.startsWith(‘/api/’)) {
return this.networkFirst(request);
}

// Static assets – Cache First
if (url.pathname.match(/\.(css|js|png|jpg|jpeg|svg|woff|woff2)$/)) {
return this.cacheFirst(request);
}

// HTML pages – Stale While Revalidate
if (request.headers.get(‘accept’).includes(‘text/html’)) {
return this.staleWhileRevalidate(request);
}

// Default – Network
return this.networkOnly(request);
}
}

// Initialize cache manager
const cacheManager = new CacheManager();

self.addEventListener(‘fetch’, (event) => {
if (event.request.method !== ‘GET’) return;

event.respondWith(
cacheManager.handleRequest(event.request)
);
});
“`

Web App Manifest Configuration

1. Complete Manifest File
“`json
// manifest.json
{
“name”: “My Progressive Web App”,
“short_name”: “PWA App”,
“description”: “A modern Progressive Web Application built with best practices”,
“start_url”: “/”,
“scope”: “/”,
“display”: “standalone”,
“orientation”: “portrait-primary”,
“theme_color”: “#2563eb”,
“background_color”: “#ffffff”,
“lang”: “en-US”,
“dir”: “ltr”,
“categories”: [“productivity”, “business”, “utilities”],

“icons”: [
{
“src”: “images/icons/icon-72×72.png”,
“sizes”: “72×72”,
“type”: “image/png”,
“purpose”: “maskable any”
},
{
“src”: “images/icons/icon-96×96.png”,
“sizes”: “96×96”,
“type”: “image/png”,
“purpose”: “maskable any”
},
{
“src”: “images/icons/icon-128×128.png”,
“sizes”: “128×128”,
“type”: “image/png”,
“purpose”: “maskable any”
},
{
“src”: “images/icons/icon-144×144.png”,
“sizes”: “144×144”,
“type”: “image/png”,
“purpose”: “maskable any”
},
{
“src”: “images/icons/icon-152×152.png”,
“sizes”: “152×152”,
“type”: “image/png”,
“purpose”: “maskable any”
},
{
“src”: “images/icons/icon-192×192.png”,
“sizes”: “192×192”,
“type”: “image/png”,
“purpose”: “maskable any”
},
{
“src”: “images/icons/icon-384×384.png”,
“sizes”: “384×384”,
“type”: “image/png”,
“purpose”: “maskable any”
},
{
“src”: “images/icons/icon-512×512.png”,
“sizes”: “512×512”,
“type”: “image/png”,
“purpose”: “maskable any”
}
],

“screenshots”: [
{
“src”: “images/screenshots/desktop-home.png”,
“sizes”: “1280×720”,
“type”: “image/png”,
“form_factor”: “wide”,
“label”: “Home screen on desktop”
},
{
“src”: “images/screenshots/mobile-home.png”,
“sizes”: “390×844”,
“type”: “image/png”,
“form_factor”: “narrow”,
“label”: “Home screen on mobile”
}
],

“shortcuts”: [
{
“name”: “New Task”,
“short_name”: “Add”,
“description”: “Create a new task quickly”,
“url”: “/new-task”,
“icons”: [
{
“src”: “images/icons/add-96×96.png”,
“sizes”: “96×96”
}
]
},
{
“name”: “Search”,
“short_name”: “Search”,
“description”: “Search tasks and content”,
“url”: “/search”,
“icons”: [
{
“src”: “images/icons/search-96×96.png”,
“sizes”: “96×96”
}
]
}
],

“share_target”: {
“action”: “/share”,
“method”: “POST”,
“enctype”: “multipart/form-data”,
“params”: {
“title”: “title”,
“text”: “text”,
“url”: “url”,
“files”: [
{
“name”: “files”,
“accept”: [“image/*”, “audio/*”, “video/*”]
}
]
}
},

“protocol_handlers”: [
{
“protocol”: “web+pwa”,
“url”: “/handle-protocol?url=%s”
}
]
}
“`

2. Dynamic Manifest Generation
“`javascript
// Dynamic manifest generation based on user preferences
class ManifestManager {
constructor() {
this.defaultManifest = {
name: “My PWA App”,
short_name: “PWA”,
display: “standalone”,
theme_color: “#2563eb”,
background_color: “#ffffff”
};
}

generateManifest(userPreferences = {}) {
const manifest = {
…this.defaultManifest,
…userPreferences,
icons: this.generateIcons(userPreferences.theme),
shortcuts: this.generateShortcuts(userPreferences.features)
};

return manifest;
}

generateIcons(theme = ‘default’) {
const iconSets = {
default: [
{ src: “icons/icon-192×192.png”, sizes: “192×192”, type: “image/png” },
{ src: “icons/icon-512×512.png”, sizes: “512×512”, type: “image/png” }
],
dark: [
{ src: “icons/dark/icon-192×192.png”, sizes: “192×192”, type: “image/png” },
{ src: “icons/dark/icon-512×512.png”, sizes: “512×512”, type: “image/png” }
]
};

return iconSets[theme] || iconSets.default;
}

generateShortcuts(features = []) {
const allShortcuts = {
tasks: {
name: “Tasks”,
url: “/tasks”,
icons: [{ src: “icons/tasks.png”, sizes: “96×96” }]
},
calendar: {
name: “Calendar”,
url: “/calendar”,
icons: [{ src: “icons/calendar.png”, sizes: “96×96” }]
},
messages: {
name: “Messages”,
url: “/messages”,
icons: [{ src: “icons/messages.png”, sizes: “96×96″ }]
}
};

return features.map(feature => allShortcuts[feature]).filter(Boolean);
}

updateManifest(manifest) {
const manifestBlob = new Blob([JSON.stringify(manifest, null, 2)], {
type: ‘application/json’
});

const manifestURL = URL.createObjectURL(manifestBlob);

// Update manifest link in document
const manifestLink = document.querySelector(‘link[rel=”manifest”]’);
if (manifestLink) {
manifestLink.href = manifestURL;
}

return manifestURL;
}
}

// Usage
const manifestManager = new ManifestManager();
const dynamicManifest = manifestManager.generateManifest({
theme: ‘dark’,
features: [‘tasks’, ‘calendar’],
name: “My Custom PWA”
});

manifestManager.updateManifest(dynamicManifest);
“`

Background Sync dan Push Notifications

1. Background Sync Implementation
“`javascript
// Background sync for offline actions
class BackgroundSyncManager {
constructor() {
this.syncRegistry = new Map();
this.setupEventListeners();
}

setupEventListeners() {
if (‘serviceWorker’ in navigator) {
navigator.serviceWorker.ready.then(registration => {
registration.addEventListener(‘sync’, (event) => {
if (event.tag === ‘background-sync’) {
event.waitUntil(this.syncData());
}
});
});
}
}

// Register sync event
async registerSync(data) {
if (‘serviceWorker’ in navigator && ‘sync’ in window.ServiceWorkerRegistration.prototype) {
try {
const registration = await navigator.serviceWorker.ready;

// Store data untuk sync
this.syncRegistry.set(‘pending-data’, data);

// Register sync
await registration.sync.register(‘background-sync’);

console.log(‘Background sync registered’);
return true;
} catch (error) {
console.error(‘Background sync registration failed:’, error);
return false;
}
} else {
// Fallback: immediately sync
return this.syncData();
}
}

async syncData() {
const pendingData = this.syncRegistry.get(‘pending-data’);

if (!pendingData) {
console.log(‘No data to sync’);
return;
}

try {
// Sync data ke server
const response = await fetch(‘/api/sync’, {
method: ‘POST’,
headers: {
‘Content-Type’: ‘application/json’,
},
body: JSON.stringify(pendingData)
});

if (response.ok) {
console.log(‘Data synced successfully’);
this.syncRegistry.delete(‘pending-data’);

// Notify user
this.showSyncNotification(‘Data synced successfully!’);
} else {
throw new Error(‘Sync failed’);
}
} catch (error) {
console.error(‘Sync failed:’, error);
this.showSyncNotification(‘Sync failed. Will retry later.’, ‘error’);
}
}

showSyncNotification(message, type = ‘success’) {
if (‘Notification’ in window && Notification.permission === ‘granted’) {
new Notification(‘PWA Sync’, {
body: message,
icon: type === ‘success’ ? ‘/icons/success.png’ : ‘/icons/error.png’,
badge: ‘/icons/badge.png’
});
}
}
}

// Service worker sync handling
self.addEventListener(‘sync’, (event) => {
if (event.tag === ‘background-sync’) {
event.waitUntil(
fetch(‘/api/sync’, {
method: ‘POST’,
headers: { ‘Content-Type’: ‘application/json’ },
body: JSON.stringify({ data: ‘pending-sync-data’ })
})
.then(response => {
if (!response.ok) throw new Error(‘Sync failed’);
console.log(‘Background sync successful’);
})
.catch(error => {
console.error(‘Background sync failed:’, error);
// Sync will be retried automatically
})
);
}
});
“`

2. Push Notifications Setup
“`javascript
// Push notification manager
class PushNotificationManager {
constructor() {
this.subscription = null;
this.publicKey = ‘BKqQy6nJz8QzNnQz8QzNnQz8QzNnQz8QzNnQz8QzNnQz8QzNnQz8QzNnQz8QzNnQzNn’;
}

async initialize() {
// Request notification permission
const permission = await this.requestPermission();

if (permission === ‘granted’) {
await this.subscribeToPush();
this.setupPushEventListeners();
}

return permission;
}

async requestPermission() {
if (!(‘Notification’ in window)) {
console.warn(‘This browser does not support notifications’);
return ‘denied’;
}

if (Notification.permission === ‘granted’) {
return ‘granted’;
}

if (Notification.permission !== ‘denied’) {
const permission = await Notification.requestPermission();
return permission;
}

return ‘denied’;
}

async subscribeToPush() {
try {
const registration = await navigator.serviceWorker.ready;

// Check existing subscription
const existingSubscription = await registration.pushManager.getSubscription();

if (existingSubscription) {
this.subscription = existingSubscription;
return existingSubscription;
}

// Subscribe to push
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: this.urlB64ToUint8Array(this.publicKey)
});

this.subscription = subscription;

// Send subscription ke server
await this.sendSubscriptionToServer(subscription);

console.log(‘Push subscription successful’);
return subscription;

} catch (error) {
console.error(‘Push subscription failed:’, error);
}
}

async sendSubscriptionToServer(subscription) {
try {
await fetch(‘/api/subscribe’, {
method: ‘POST’,
headers: {
‘Content-Type’: ‘application/json’,
},
body: JSON.stringify(subscription)
});
} catch (error) {
console.error(‘Failed to send subscription to server:’, error);
}
}

setupPushEventListeners() {
navigator.serviceWorker.addEventListener(‘message’, (event) => {
if (event.data && event.data.type === ‘PUSH_RECEIVED’) {
this.handlePushReceived(event.data.payload);
}
});
}

handlePushReceived(payload) {
// Handle incoming push notification
console.log(‘Push received:’, payload);

// Update UI, show badge, dll
this.updateUIWithNewData(payload);
}

updateUIWithNewData(data) {
// Update application state
const event = new CustomEvent(‘pwa-push-received’, { detail: data });
window.dispatchEvent(event);
}

// Utility function
urlB64ToUint8Array(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 {
const options = {
body: event.data ? event.data.text() : ‘New message from PWA’,
icon: ‘/icons/icon-192×192.png’,
badge: ‘/icons/badge.png’,
vibrate: [100, 50, 100],
data: {
dateOfArrival: Date.now(),
primaryKey: ‘2’
},
actions: [
{
action: ‘explore’,
title: ‘Explore’,
icon: ‘/icons/checkmark.png’
},
{
action: ‘close’,
title: ‘Close’,
icon: ‘/icons/xmark.png’
}
]
};

event.waitUntil(
self.registration.showNotification(‘PWA Notification’, options)
);
});

self.addEventListener(‘notificationclick’, (event) => {
console.log(‘Notification click received.’);

event.notification.close();

if (event.action === ‘explore’) {
// Open app to specific page
event.waitUntil(
clients.openWindow(‘/explore’)
);
} else if (event.action === ‘close’) {
// Just close notification
return;
} else {
// Open app to homepage
event.waitUntil(
clients.openWindow(‘/’)
);
}
});
“`

PWA Installation dan User Experience

1. Install Prompt Management
“`javascript
// PWA Install prompt manager
class PWAInstallManager {
constructor() {
this.deferredPrompt = null;
this.installButton = null;
this.setupInstallPrompt();
}

setupInstallPrompt() {
window.addEventListener(‘beforeinstallprompt’, (event) => {
// Prevent default install prompt
event.preventDefault();

// Store event untuk later use
this.deferredPrompt = event;

// Show install button
this.showInstallButton();
});

// Handle successful install
window.addEventListener(‘appinstalled’, (event) => {
console.log(‘PWA was installed’);
this.hideInstallButton();

// Track installation analytics
this.trackInstallation();
});
}

showInstallButton() {
// Create or show install button
if (!this.installButton) {
this.installButton = document.createElement(‘button’);
this.installButton.innerHTML = `

Install App
`;
this.installButton.className = ‘pwa-install-button’;
this.installButton.addEventListener(‘click’, () => this.promptInstall());

document.body.appendChild(this.installButton);
}

// Animate button appearance
setTimeout(() => {
this.installButton.classList.add(‘show’);
}, 1000);
}

hideInstallButton() {
if (this.installButton) {
this.installButton.classList.remove(‘show’);
setTimeout(() => {
if (this.installButton.parentNode) {
this.installButton.parentNode.removeChild(this.installButton);
}
}, 300);
}
}

async promptInstall() {
if (!this.deferredPrompt) {
console.log(‘Install prompt not available’);
return;
}

try {
// Show install prompt
this.deferredPrompt.prompt();

// Wait for user response
const { outcome } = await this.deferredPrompt.userChoice;

console.log(`User ${outcome} the install prompt`);

// Clear deferred prompt
this.deferredPrompt = null;

if (outcome === ‘accepted’) {
this.hideInstallButton();
}

} catch (error) {
console.error(‘Error showing install prompt:’, error);
}
}

trackInstallation() {
// Send installation data ke analytics
if (typeof gtag !== ‘undefined’) {
gtag(‘event’, ‘pwa_installed’, {
‘event_category’: ‘PWA’,
‘event_label’: ‘Install Success’
});
}
}

// Check if PWA is already installed
isInstalled() {
// For iOS
if (‘standalone’ in window.navigator && window.navigator.standalone) {
return true;
}

// For Android
if (window.matchMedia(‘(display-mode: standalone)’).matches) {
return true;
}

return false;
}
}

// Usage
const installManager = new PWAInstallManager();

// CSS untuk install button
const pwaInstallStyles = `
.pwa-install-button {
position: fixed;
bottom: 20px;
right: 20px;
background: #2563eb;
color: white;
border: none;
padding: 12px 20px;
border-radius: 50px;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
font-family: -apple-system, BlinkMacSystemFont, ‘Segoe UI’, Roboto, sans-serif;
font-size: 16px;
font-weight: 500;
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.3);
transform: translateY(100px);
opacity: 0;
transition: all 0.3s ease;
z-index: 1000;
}

.pwa-install-button.show {
transform: translateY(0);
opacity: 1;
}

.pwa-install-button:hover {
background: #1d4ed8;
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(37, 99, 235, 0.4);
}

.pwa-install-button:active {
transform: translateY(0);
}

@media (max-width: 768px) {
.pwa-install-button {
bottom: 16px;
right: 16px;
padding: 10px 16px;
font-size: 14px;
}
}
`;

// Inject styles
const styleSheet = document.createElement(‘style’);
styleSheet.textContent = pwaInstallStyles;
document.head.appendChild(styleSheet);
“`

Offline-First Architecture

1. Application Shell Pattern
“`javascript
// Application Shell Manager
class AppShellManager {
constructor() {
this.shellCache = ‘shell-v1’;
this.contentCache = ‘content-v1’;
this.setupShell();
}

setupShell() {
// Define shell components
this.shellComponents = [
‘/’,
‘/index.html’,
‘/css/shell.css’,
‘/js/shell.js’,
‘/js/navigation.js’,
‘/images/logo.svg’,
‘/manifest.json’
];

// Cache shell components
this.cacheShell();
}

async cacheShell() {
if (‘caches’ in window) {
const cache = await caches.open(this.shellCache);
await cache.addAll(this.shellComponents);
}
}

// Load shell immediately
async loadShell() {
try {
const cache = await caches.open(this.shellCache);
const shellResponse = await cache.match(‘/js/shell.js’);

if (shellResponse) {
const shellCode = await shellResponse.text();
eval(shellCode); // Execute shell code
return true;
}
} catch (error) {
console.error(‘Failed to load shell:’, error);
}

return false;
}

// Dynamic content loading
async loadContent(url) {
try {
// Try network first
const response = await fetch(url);

if (response.ok) {
const content = await response.text();

// Cache content for offline
const cache = await caches.open(this.contentCache);
await cache.put(url, new Response(content));

return content;
}
} catch (error) {
// Fallback to cache
const cache = await caches.open(this.contentCache);
const cachedResponse = await cache.match(url);

if (cachedResponse) {
return cachedResponse.text();
}
}

return null;
}
}

// Shell implementation
const appShell = `

My PWA App


`;
“`

Performance Optimization

1. Resource Optimization
“`javascript
// Performance optimization manager
class PerformanceManager {
constructor() {
this.setupPerformanceMonitoring();
this.optimizeResources();
}

setupPerformanceMonitoring() {
// Monitor Core Web Vitals
this.measureCoreWebVitals();

// Monitor custom metrics
this.setupCustomMetrics();
}

measureCoreWebVitals() {
// Largest Contentful Paint (LCP)
new PerformanceObserver((entryList) => {
const entries = entryList.getEntries();
const lastEntry = entries[entries.length – 1];
console.log(‘LCP:’, lastEntry.startTime);

// Send ke analytics
this.sendMetric(‘LCP’, lastEntry.startTime);
}).observe({ entryTypes: [‘largest-contentful-paint’] });

// First Input Delay (FID)
new PerformanceObserver((entryList) => {
const entries = entryList.getEntries();
entries.forEach((entry) => {
console.log(‘FID:’, entry.processingStart – entry.startTime);
this.sendMetric(‘FID’, entry.processingStart – entry.startTime);
});
}).observe({ entryTypes: [‘first-input’] });

// Cumulative Layout Shift (CLS)
let clsValue = 0;
new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) {
if (!entry.hadRecentInput) {
clsValue += entry.value;
}
}
console.log(‘CLS:’, clsValue);
this.sendMetric(‘CLS’, clsValue);
}).observe({ entryTypes: [‘layout-shift’] });
}

setupCustomMetrics() {
// Time to Interactive
const measureTTI = () => {
const tti = performance.now() – navigationTiming.loadEventEnd;
console.log(‘TTI:’, tti);
this.sendMetric(‘TTI’, tti);
};

if (document.readyState === ‘complete’) {
setTimeout(measureTTI, 0);
} else {
window.addEventListener(‘load’, () => {
setTimeout(measureTTI, 0);
});
}
}

optimizeResources() {
// Lazy loading images
this.setupLazyLoading();

// Preload critical resources
this.preloadCriticalResources();

// Optimize fonts
this.optimizeFonts();
}

setupLazyLoading() {
const images = document.querySelectorAll(‘img[data-src]’);

const imageObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
img.classList.remove(‘lazy’);
imageObserver.unobserve(img);
}
});
});

images.forEach(img => imageObserver.observe(img));
}

preloadCriticalResources() {
const criticalResources = [
{ href: ‘/css/critical.css’, as: ‘style’ },
{ href: ‘/js/critical.js’, as: ‘script’ },
{ href: ‘/fonts/inter.woff2’, as: ‘font’, type: ‘font/woff2’, crossorigin: ‘true’ }
];

criticalResources.forEach(resource => {
const link = document.createElement(‘link’);
link.rel = ‘preload’;
link.href = resource.href;
link.as = resource.as;

if (resource.type) link.type = resource.type;
if (resource.crossorigin) link.crossOrigin = resource.crossorigin;

document.head.appendChild(link);
});
}

optimizeFonts() {
// Font display strategy
const fontDisplay = `
@font-face {
font-family: ‘Inter’;
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(‘/fonts/inter-regular.woff2’) format(‘woff2’);
}
`;

const styleSheet = document.createElement(‘style’);
styleSheet.textContent = fontDisplay;
document.head.appendChild(styleSheet);
}

sendMetric(name, value) {
// Send ke analytics service
if (typeof gtag !== ‘undefined’) {
gtag(‘event’, ‘web_vital’, {
‘event_category’: ‘Performance’,
‘event_label’: name,
‘value’: Math.round(value)
});
}
}
}

// Initialize performance manager
const performanceManager = new PerformanceManager();
“`

Testing PWA

1. PWA Testing Framework
“`javascript
// PWA Testing Utilities
class PWATester {
constructor() {
this.tests = [];
this.results = [];
}

// Add test case
addTest(name, testFunction, category = ‘general’) {
this.tests.push({ name, testFunction, category });
}

// Run all tests
async runTests() {
this.results = [];

for (const test of this.tests) {
try {
const result = await test.testFunction();
this.results.push({
name: test.name,
category: test.category,
passed: result.passed,
message: result.message,
details: result.details
});
} catch (error) {
this.results.push({
name: test.name,
category: test.category,
passed: false,
message: `Test failed: ${error.message}`,
details: { error: error.stack }
});
}
}

return this.results;
}

// Get test results summary
getSummary() {
const passed = this.results.filter(r => r.passed).length;
const total = this.results.length;

return {
passed,
failed: total – passed,
total,
passRate: (passed / total) * 100
};
}

// Generate HTML report
generateReport() {
const summary = this.getSummary();

let html = `

PWA Test Results

Passed: ${summary.passed}
Failed: ${summary.failed}
Total: ${summary.total}
Pass Rate: ${summary.passRate.toFixed(1)}%
`;

const categories = […new Set(this.tests.map(t => t.category))];

categories.forEach(category => {
html += `

${category}

`;

const categoryResults = this.results.filter(r => {
const test = this.tests.find(t => t.name === r.name);
return test && test.category === category;
});

categoryResults.forEach(result => {
html += `

${result.name}
${result.passed ? ‘✓’ : ‘✗’}

${result.message}

${result.details ? `

${JSON.stringify(result.details, null, 2)}

` : ”}

`;
});
});

html += `

`;

return html;
}
}

// Common PWA tests
const pwaTests = new PWATester();

// Service Worker test
pwaTests.addTest(‘Service Worker Registration’, async () => {
if (!(‘serviceWorker’ in navigator)) {
return { passed: false, message: ‘Service Worker not supported’ };
}

const registration = await navigator.serviceWorker.ready;
if (registration.active) {
return { passed: true, message: ‘Service Worker is active’ };
} else {
return { passed: false, message: ‘Service Worker not active’ };
}
}, ‘core’);

// Manifest test
pwaTests.addTest(‘Web App Manifest’, async () => {
try {
const response = await fetch(‘/manifest.json’);
if (response.ok) {
const manifest = await response.json();

const requiredFields = [‘name’, ‘short_name’, ‘start_url’, ‘display’];
const missingFields = requiredFields.filter(field => !manifest[field]);

if (missingFields.length === 0) {
return { passed: true, message: ‘Manifest has all required fields’, details: manifest };
} else {
return { passed: false, message: `Missing fields: ${missingFields.join(‘, ‘)}` };
}
} else {
return { passed: false, message: ‘Manifest not found’ };
}
} catch (error) {
return { passed: false, message: ‘Error loading manifest’, details: error.message };
}
}, ‘core’);

// HTTPS test
pwaTests.addTest(‘HTTPS Connection’, async () => {
const isHTTPS = location.protocol === ‘https:’ || location.hostname === ‘localhost’;

if (isHTTPS) {
return { passed: true, message: ‘Site is served over HTTPS’ };
} else {
return { passed: false, message: ‘Site must be served over HTTPS for PWA’ };
}
}, ‘core’);

// Responsive design test
pwaTests.addTest(‘Responsive Design’, async () => {
const viewportMeta = document.querySelector(‘meta[name=”viewport”]’);
const hasViewportMeta = viewportMeta && viewportMeta.content.includes(‘width=device-width’);

return {
passed: hasViewportMeta,
message: hasViewportMeta ? ‘Viewport meta tag present’ : ‘Missing viewport meta tag’
};
}, ‘ux’);

// Offline functionality test
pwaTests.addTest(‘Offline Functionality’, async () => {
try {
const response = await fetch(‘/’);
const isFromCache = response.headers.get(‘X-From-Service-Worker’);

return {
passed: true,
message: ‘Page loads successfully’,
details: { fromCache: !!isFromCache }
};
} catch (error) {
return { passed: false, message: ‘Failed to load page’, details: error.message };
}
}, ‘performance’);

// Run tests dan display results
async function runPWATests() {
const results = await pwaTests.runTests();
const report = pwaTests.generateReport();

// Display report
const reportContainer = document.createElement(‘div’);
reportContainer.innerHTML = report;
document.body.appendChild(reportContainer);

console.log(‘PWA Test Results:’, results);
}

// Usage di development
if (process.env.NODE_ENV === ‘development’) {
runPWATests();
}
“`

2. Lighthouse CI Integration
“`yaml
# .github/workflows/lighthouse.yml
name: Lighthouse CI

on:
push:
branches: [ main ]
pull_request:
branches: [ main ]

jobs:
lighthouse:
runs-on: ubuntu-latest

steps:
– uses: actions/checkout@v3

– name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: ’18’

– name: Install dependencies
run: npm install

– name: Build application
run: npm run build

– name: Run Lighthouse CI
run: |
npm install -g @lhci/[email protected]
lhci autorun
env:
LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}

– name: Upload Lighthouse results
uses: actions/upload-artifact@v3
with:
name: lighthouse-results
path: ‘.lighthouseci’
“`

PWA Best Practices dan Guidelines

1. Performance Guidelines
• Time to Interactive: < 5 seconds on 3G
• First Contentful Paint: < 1.5 seconds
• Largest Contentful Paint: < 2.5 seconds
• Cumulative Layout Shift: < 0.1

2. Security Requirements
• HTTPS Mandatory: All PWA resources must be served over HTTPS
• CSP Headers: Implement Content Security Policy
• Service Worker Security: Validate all requests
• Data Protection: Secure user data storage

3. User Experience Guidelines
• Offline First: Design for offline functionality first
• Progressive Enhancement: Works di semua browsers
• Responsive Design: Optimize untuk semua screen sizes
• Accessibility: WCAG 2.1 AA compliance

Future of PWA Technology

1. Emerging Features
• Web Share Target Level 2: Enhanced sharing capabilities
• Web OTP: One-time password API
• File System Access: Direct file system access
• Web NFC: Near field communication
• Background Fetch: Background data synchronization

2. Platform Integration
• Desktop PWA: Enhanced desktop integration
• Windows 11 Widgets: PWA as Windows widgets
• macOS Integration: Native macOS features
• Chrome OS: Deep OS integration

3. Performance Improvements
• WebAssembly Integration: High-performance computing
• WebGPU: Graphics acceleration
• WebCodecs: Media processing
• Portals: Seamless navigation between apps

Kesimpulan

Progressive Web Apps represent evolution dalam web development, menggabungkan keunggulan web reachability dengan native app experience. Dengan proper implementation dari service workers, web app manifest, dan modern web capabilities, PWA dapat deliver exceptional user experience across all platforms.

Key success factors:
• Performance: Optimize untuk Core Web Vitals
• Offline Capability: Design offline-first architecture
• User Experience: Native-like interactions dan animations
• Security: HTTPS implementation dan secure coding practices
• Testing: Comprehensive PWA testing strategy

Investasi dalam PWA development akan memberikan benefits:
– Increased user engagement dan retention
– Improved performance dan reliability
– Better conversion rates
– Reduced development costs
– Cross-platform compatibility

Start dengan implementing core PWA features, measure performance, dan iterate based on user feedback. PWA adalah future-proof approach untuk modern web applications.