Menu
📱 Lihat versi lengkap (non-AMP)
Programming Web Development

Cara Membuat Aplikasi PWA (Progressive Web App)

Editor: Hendra WIjaya
Update: 7 January 2026
Baca: 6 menit

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

2. Service Worker
   - Offline functionality
   - Caching
   - Push notifications

3. 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" }]
    }
  ]
}
<!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 report

Check:
- 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 offline

Kesimpulan

PWA memberikan user experience seperti native app dengan reach dari web. Focus pada core features: manifest, service worker, dan offline support.

Artikel Terkait

Bagikan:

Link Postingan: https://www.tirinfo.com/cara-membuat-aplikasi-pwa/