Progressive Web App (PWA) menggabungkan best features dari web dan native apps. Mari pelajari cara membuatnya.
Apa itu PWA?
Karakteristik PWA
Progressive: Work untuk semua browser
Responsive: Fit di semua screen sizes
Connectivity Independent: Work offline
App-like: Feel seperti native app
Fresh: Always up-to-date
Safe: HTTPS required
Discoverable: SEO friendly
Re-engageable: Push notifications
Installable: Bisa di-install ke device
Linkable: Easy to share via URL
PWA vs Native App
PWA:
+ Single codebase
+ No app store needed
+ Smaller size
+ Auto updates
+ SEO benefits
- Limited device access
- Belum semua feature
Native App:
- Full device access
- Better performance
- App store visibility
- Platform specific
- Larger size
- App store approval
Requirements
Core Components
1. Web App Manifest
- App metadata
- Icons
- Display mode
- Service Worker
- Offline functionality
- Caching
- Push notifications
- HTTPS
- Required untuk service worker
- Security
Web App Manifest
manifest.json
{
"name": "My PWA App",
"short_name": "MyPWA",
"description": "A sample Progressive Web App",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#2196F3",
"orientation": "portrait-primary",
"scope": "/",
"icons": [
{
"src": "/icons/icon-72x72.png",
"sizes": "72x72",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icons/icon-96x96.png",
"sizes": "96x96",
"type": "image/png"
},
{
"src": "/icons/icon-128x128.png",
"sizes": "128x128",
"type": "image/png"
},
{
"src": "/icons/icon-144x144.png",
"sizes": "144x144",
"type": "image/png"
},
{
"src": "/icons/icon-152x152.png",
"sizes": "152x152",
"type": "image/png"
},
{
"src": "/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-384x384.png",
"sizes": "384x384",
"type": "image/png"
},
{
"src": "/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"screenshots": [
{
"src": "/screenshots/desktop.png",
"sizes": "1280x720",
"type": "image/png",
"form_factor": "wide"
},
{
"src": "/screenshots/mobile.png",
"sizes": "750x1334",
"type": "image/png",
"form_factor": "narrow"
}
],
"shortcuts": [
{
"name": "New Post",
"short_name": "New",
"description": "Create a new post",
"url": "/new",
"icons": [{ "src": "/icons/new.png", "sizes": "96x96" }]
}
]
}
Link Manifest di HTML
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /><!-- PWA Meta Tags --> <link rel="manifest" href="/manifest.json" /> <meta name="theme-color" content="#2196F3" /> <meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-status-bar-style" content="default" /> <meta name="apple-mobile-web-app-title" content="My PWA" /> <!-- iOS Icons --> <link rel="apple-touch-icon" href="/icons/icon-152x152.png" /> <title>My PWA App</title></head>
<body>
<!-- App content --><script src="/js/app.js"></script></body>
</html>Service Worker
Register Service Worker
// app.js if ("serviceWorker" in navigator) { window.addEventListener("load", async () => { try { const registration = await navigator.serviceWorker.register("/sw.js"); console.log("ServiceWorker registered:", registration.scope); } catch (error) { console.log("ServiceWorker registration failed:", error); } }); }Basic Service Worker
// sw.js const CACHE_NAME = "my-pwa-cache-v1"; const urlsToCache = [ "/", "/index.html", "/css/styles.css", "/js/app.js", "/icons/icon-192x192.png", "/offline.html", ];// Install event - cache assets self.addEventListener("install", (event) => { event.waitUntil( caches.open(CACHE_NAME).then((cache) => { console.log("Opened cache"); return cache.addAll(urlsToCache); }) ); // Activate immediately self.skipWaiting(); });
// Activate event - clean old caches self.addEventListener("activate", (event) => { event.waitUntil( caches.keys().then((cacheNames) => { return Promise.all( cacheNames.map((cacheName) => { if (cacheName !== CACHE_NAME) { console.log("Deleting old cache:", cacheName); return caches.delete(cacheName); } }) ); }) ); // Take control immediately self.clients.claim(); });
// Fetch event - serve from cache or network self.addEventListener("fetch", (event) => { event.respondWith( caches .match(event.request) .then((response) => { // Cache hit - return response if (response) { return response; }
// Clone request const fetchRequest = event.request.clone(); return fetch(fetchRequest).then((response) => { // Check valid response if ( !response || response.status !== 200 || response.type !== "basic" ) { return response; } // Clone response const responseToCache = response.clone(); // Cache new response caches.open(CACHE_NAME).then((cache) => { cache.put(event.request, responseToCache); }); return response; }); }) .catch(() => { // Offline fallback if (event.request.mode === "navigate") { return caches.match("/offline.html"); } }));
});Caching Strategies
Cache First
// Good for: static assets (CSS, JS, images) self.addEventListener("fetch", (event) => { event.respondWith( caches.match(event.request).then((cached) => cached || fetch(event.request)) ); });Network First
// Good for: API calls, frequently updated content self.addEventListener("fetch", (event) => { event.respondWith( fetch(event.request) .then((response) => { // Update cache const clone = response.clone(); caches.open(CACHE_NAME).then((cache) => { cache.put(event.request, clone); }); return response; }) .catch(() => caches.match(event.request)) ); });Stale While Revalidate
// Good for: balanced freshness and performance self.addEventListener("fetch", (event) => { event.respondWith( caches.open(CACHE_NAME).then((cache) => { return cache.match(event.request).then((cached) => { const fetchPromise = fetch(event.request).then((response) => { cache.put(event.request, response.clone()); return response; }); return cached || fetchPromise; }); }) ); });Advanced Cache Strategy
// sw.js - Different strategies for different requests const CACHE_NAME = "my-pwa-v1"; const STATIC_ASSETS = ["/", "/index.html", "/css/styles.css", "/js/app.js"];self.addEventListener("fetch", (event) => { const { request } = event; const url = new URL(request.url);
// Static assets - Cache First if (STATIC_ASSETS.includes(url.pathname)) { event.respondWith(cacheFirst(request)); return; }
// API calls - Network First if (url.pathname.startsWith("/api/")) { event.respondWith(networkFirst(request)); return; }
// Images - Cache First with fallback if (request.destination === "image") { event.respondWith(cacheFirst(request)); return; }
// Default - Stale While Revalidate event.respondWith(staleWhileRevalidate(request)); });
async function cacheFirst(request) { const cached = await caches.match(request); return cached || fetch(request); }
async function networkFirst(request) { try { const response = await fetch(request); const cache = await caches.open(CACHE_NAME); cache.put(request, response.clone()); return response; } catch { return caches.match(request); } }
async function staleWhileRevalidate(request) { const cache = await caches.open(CACHE_NAME); const cached = await cache.match(request);
const fetchPromise = fetch(request).then((response) => { cache.put(request, response.clone()); return response; });
return cached || fetchPromise; }
Push Notifications
Request Permission
async function requestNotificationPermission() { const permission = await Notification.requestPermission();if (permission === "granted") { console.log("Notification permission granted"); await subscribeUserToPush(); } else { console.log("Notification permission denied"); } }
Subscribe to Push
async function subscribeUserToPush() { const registration = await navigator.serviceWorker.ready;const subscription = await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(PUBLIC_VAPID_KEY), });
// Send subscription to server await fetch("/api/subscribe", { method: "POST", body: JSON.stringify(subscription), headers: { "Content-Type": "application/json", }, }); }
function 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; }
Handle Push in Service Worker
// sw.js self.addEventListener("push", (event) => { const data = event.data?.json() ?? {};const options = { body: data.body || "New notification", icon: "/icons/icon-192x192.png", badge: "/icons/badge-72x72.png", vibrate: [100, 50, 100], data: { url: data.url || "/", }, actions: [ { action: "open", title: "Open" }, { action: "dismiss", title: "Dismiss" }, ], };
event.waitUntil( self.registration.showNotification(data.title || "Notification", options) ); });
self.addEventListener("notificationclick", (event) => { event.notification.close();
if (event.action === "open" || !event.action) { event.waitUntil(clients.openWindow(event.notification.data.url)); } });
Install Prompt
Custom Install Button
let deferredPrompt;window.addEventListener("beforeinstallprompt", (e) => { // Prevent default prompt e.preventDefault(); // Store event for later deferredPrompt = e; // Show install button document.getElementById("installBtn").style.display = "block"; });
document.getElementById("installBtn").addEventListener("click", async () => { if (!deferredPrompt) return;
// Show prompt deferredPrompt.prompt();
// Wait for user response const { outcome } = await deferredPrompt.userChoice; console.log(
User response: ${outcome});// Clear stored prompt deferredPrompt = null; // Hide install button document.getElementById("installBtn").style.display = "none"; });
window.addEventListener("appinstalled", () => { console.log("PWA was installed"); deferredPrompt = null; });
Background Sync
Register Sync
// app.js async function sendData(data) { try { await fetch("/api/data", { method: "POST", body: JSON.stringify(data), }); } catch { // Store for later await saveToIndexedDB(data);// Register sync const registration = await navigator.serviceWorker.ready; await registration.sync.register("sync-data");}
}Handle Sync in Service Worker
// sw.js self.addEventListener("sync", (event) => { if (event.tag === "sync-data") { event.waitUntil(syncData()); } });async function syncData() { const data = await getFromIndexedDB();
for (const item of data) { try { await fetch("/api/data", { method: "POST", body: JSON.stringify(item), }); await removeFromIndexedDB(item.id); } catch { // Will retry later throw new Error("Sync failed"); } } }
PWA dengan Framework
Next.js PWA
npm install next-pwa// next.config.js const withPWA = require("next-pwa")({ dest: "public", register: true, skipWaiting: true, disable: process.env.NODE_ENV === "development", });module.exports = withPWA({ // Next.js config });
Vue PWA
vue add pwa// vue.config.js module.exports = { pwa: { name: "My PWA", themeColor: "#4DBA87", msTileColor: "#000000", manifestOptions: { start_url: "/", }, workboxOptions: { skipWaiting: true, }, }, };Create React App PWA
npx create-react-app my-pwa --template cra-template-pwa// src/index.js import * as serviceWorkerRegistration from "./serviceWorkerRegistration";serviceWorkerRegistration.register();
Testing PWA
Lighthouse Audit
Chrome DevTools: 1. Open DevTools (F12) 2. Go to Lighthouse tab 3. Select "Progressive Web App" 4. Generate reportCheck:
- Installable
- PWA Optimized
- Fast and reliable
PWA Checklist
□ HTTPS enabled □ manifest.json linked □ Service worker registered □ Icons (all sizes) □ Offline page works □ Fast load time (< 3s) □ Responsive design □ Valid manifest □ Start URL works offlineKesimpulan
PWA memberikan user experience seperti native app dengan reach dari web. Focus pada core features: manifest, service worker, dan offline support.
Ditulis oleh
Hendra Wijaya