PWA Implementation Guide - Achieve Native Experience with Progressive Web Apps

2025.12.02

What is PWA

PWA (Progressive Web App) is a technology that provides native app-like experience to web applications. It enables offline operation, push notifications, and home screen installation.

flowchart TB
    subgraph PWA["Key Features of PWA"]
        subgraph Install["Installable"]
            I1["Add icon to home screen"]
            I2["No app store required"]
            I3["Full screen display"]
        end

        subgraph Offline["Offline Support"]
            O1["Caching with Service Worker"]
            O2["Works without network"]
            O3["Background sync"]
        end

        subgraph Push["Push Notifications"]
            P1["Receive notifications even with browser closed"]
            P2["Improve engagement"]
            P3["Re-engagement"]
        end
    end

Web App Manifest

// public/manifest.json
{
  "name": "My Progressive Web App",
  "short_name": "MyPWA",
  "description": "An example Progressive Web App",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#3b82f6",
  "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-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"
    }
  ],
  "shortcuts": [
    {
      "name": "Create New",
      "short_name": "Create",
      "description": "Create a new item",
      "url": "/new",
      "icons": [{ "src": "/icons/new.png", "sizes": "96x96" }]
    }
  ],
  "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"
    }
  ]
}
<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>My PWA</title>

  <!-- PWA required tags -->
  <link rel="manifest" href="/manifest.json">
  <meta name="theme-color" content="#3b82f6">

  <!-- iOS Safari support -->
  <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="MyPWA">
  <link rel="apple-touch-icon" href="/icons/icon-192x192.png">

  <!-- Windows support -->
  <meta name="msapplication-TileColor" content="#3b82f6">
  <meta name="msapplication-TileImage" content="/icons/icon-144x144.png">
</head>
<body>
  <div id="app"></div>
  <script src="/app.js" type="module"></script>
</body>
</html>

Service Worker

Basic Service Worker

// public/sw.js
const CACHE_NAME = 'my-pwa-v1';
const STATIC_ASSETS = [
  '/',
  '/index.html',
  '/app.js',
  '/styles.css',
  '/icons/icon-192x192.png',
  '/offline.html',
];

// Cache on install
self.addEventListener('install', (event: ExtendableEvent) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => {
      console.log('Caching static assets');
      return cache.addAll(STATIC_ASSETS);
    })
  );
  // Activate immediately
  self.skipWaiting();
});

// Delete old cache on activate
self.addEventListener('activate', (event: ExtendableEvent) => {
  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames
          .filter((name) => name !== CACHE_NAME)
          .map((name) => caches.delete(name))
      );
    })
  );
  // Take control of all clients immediately
  self.clients.claim();
});

// Fetch strategy
self.addEventListener('fetch', (event: FetchEvent) => {
  const { request } = event;

  // Network first for API requests
  if (request.url.includes('/api/')) {
    event.respondWith(networkFirst(request));
    return;
  }

  // Cache first for static assets
  event.respondWith(cacheFirst(request));
});

// Cache first strategy
async function cacheFirst(request: Request): Promise<Response> {
  const cached = await caches.match(request);
  if (cached) {
    return cached;
  }

  try {
    const response = await fetch(request);
    const cache = await caches.open(CACHE_NAME);
    cache.put(request, response.clone());
    return response;
  } catch (error) {
    // Offline fallback
    const offlinePage = await caches.match('/offline.html');
    return offlinePage || new Response('Offline', { status: 503 });
  }
}

// Network first strategy
async function networkFirst(request: Request): Promise<Response> {
  try {
    const response = await fetch(request);
    const cache = await caches.open(CACHE_NAME);
    cache.put(request, response.clone());
    return response;
  } catch (error) {
    const cached = await caches.match(request);
    if (cached) {
      return cached;
    }
    return new Response(JSON.stringify({ error: 'Offline' }), {
      status: 503,
      headers: { 'Content-Type': 'application/json' },
    });
  }
}

Service Worker Registration

// src/registerSW.ts
export async function registerServiceWorker(): Promise<void> {
  if (!('serviceWorker' in navigator)) {
    console.log('Service Worker not supported');
    return;
  }

  try {
    const registration = await navigator.serviceWorker.register('/sw.js', {
      scope: '/',
    });

    console.log('Service Worker registered:', registration.scope);

    // Check for updates
    registration.addEventListener('updatefound', () => {
      const newWorker = registration.installing;
      if (!newWorker) return;

      newWorker.addEventListener('statechange', () => {
        if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
          // New version available
          showUpdateNotification();
        }
      });
    });

    // Periodically check for updates
    setInterval(() => {
      registration.update();
    }, 60 * 60 * 1000); // Every hour

  } catch (error) {
    console.error('Service Worker registration failed:', error);
  }
}

function showUpdateNotification(): void {
  if (confirm('A new version is available. Update now?')) {
    window.location.reload();
  }
}

Caching Strategies

Caching Strategy Patterns

StrategyFlowUse Cases
Cache FirstRequest → Cache → (miss) → Network → CacheStatic assets, infrequently changed
Network FirstRequest → Network → (fail) → CacheAPI requests, when latest data needed
Stale While RevalidateRequest → Cache (return immediately) + Network updateUser avatars, settings data
Cache OnlyRequest → Cache onlyOffline-only content
Network OnlyRequest → Network onlyAuthentication, payments, realtime data

Stale While Revalidate Implementation

// sw.js
async function staleWhileRevalidate(request: Request): Promise<Response> {
  const cache = await caches.open(CACHE_NAME);
  const cached = await cache.match(request);

  // Update in background
  const fetchPromise = fetch(request).then((response) => {
    cache.put(request, response.clone());
    return response;
  });

  // Return cache immediately if available, otherwise wait for network
  return cached || fetchPromise;
}

Push Notifications

Server Side (Node.js)

// server/push.ts
import webpush from 'web-push';

// Generate VAPID keys (first time only)
// const vapidKeys = webpush.generateVAPIDKeys();

const VAPID_PUBLIC_KEY = process.env.VAPID_PUBLIC_KEY!;
const VAPID_PRIVATE_KEY = process.env.VAPID_PRIVATE_KEY!;

webpush.setVapidDetails(
  'mailto:admin@example.com',
  VAPID_PUBLIC_KEY,
  VAPID_PRIVATE_KEY
);

interface PushSubscription {
  endpoint: string;
  keys: {
    p256dh: string;
    auth: string;
  };
}

export async function sendPushNotification(
  subscription: PushSubscription,
  payload: {
    title: string;
    body: string;
    icon?: string;
    badge?: string;
    url?: string;
  }
): Promise<void> {
  try {
    await webpush.sendNotification(
      subscription,
      JSON.stringify(payload)
    );
    console.log('Push notification sent');
  } catch (error: any) {
    if (error.statusCode === 410) {
      // Subscription invalidated
      console.log('Subscription expired');
      // Delete from DB
    }
    throw error;
  }
}

Client Side

// src/pushNotifications.ts
const VAPID_PUBLIC_KEY = 'YOUR_VAPID_PUBLIC_KEY';

export async function subscribeToPush(): Promise<PushSubscription | null> {
  if (!('PushManager' in window)) {
    console.log('Push notifications not supported');
    return null;
  }

  const permission = await Notification.requestPermission();
  if (permission !== 'granted') {
    console.log('Notification permission denied');
    return null;
  }

  const registration = await navigator.serviceWorker.ready;

  const subscription = await registration.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
  });

  // Send subscription info to server
  await fetch('/api/push/subscribe', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(subscription),
  });

  return subscription;
}

function urlBase64ToUint8Array(base64String: string): Uint8Array {
  const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
  const base64 = (base64String + padding)
    .replace(/-/g, '+')
    .replace(/_/g, '/');
  const rawData = window.atob(base64);
  return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0)));
}

Receiving in Service Worker

// sw.js
self.addEventListener('push', (event: PushEvent) => {
  const data = event.data?.json() ?? {
    title: 'New Notification',
    body: 'You have a new message',
  };

  const options: NotificationOptions = {
    body: data.body,
    icon: data.icon || '/icons/icon-192x192.png',
    badge: data.badge || '/icons/badge-72x72.png',
    vibrate: [100, 50, 100],
    data: {
      url: data.url || '/',
    },
    actions: [
      { action: 'open', title: 'Open' },
      { action: 'close', title: 'Close' },
    ],
  };

  event.waitUntil(
    self.registration.showNotification(data.title, options)
  );
});

self.addEventListener('notificationclick', (event: NotificationEvent) => {
  event.notification.close();

  if (event.action === 'close') {
    return;
  }

  const url = event.notification.data?.url || '/';

  event.waitUntil(
    clients.matchAll({ type: 'window' }).then((windowClients) => {
      // Focus existing window if available
      for (const client of windowClients) {
        if (client.url === url && 'focus' in client) {
          return client.focus();
        }
      }
      // Open new window if not
      return clients.openWindow(url);
    })
  );
});

Install Prompt

// src/installPrompt.ts
let deferredPrompt: BeforeInstallPromptEvent | null = null;

interface BeforeInstallPromptEvent extends Event {
  prompt(): Promise<void>;
  userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
}

window.addEventListener('beforeinstallprompt', (e: Event) => {
  // Prevent default prompt
  e.preventDefault();
  deferredPrompt = e as BeforeInstallPromptEvent;

  // Show custom install button
  showInstallButton();
});

function showInstallButton(): void {
  const installButton = document.getElementById('install-button');
  if (installButton) {
    installButton.style.display = 'block';
    installButton.addEventListener('click', handleInstallClick);
  }
}

async function handleInstallClick(): Promise<void> {
  if (!deferredPrompt) return;

  // Show install prompt
  deferredPrompt.prompt();

  const { outcome } = await deferredPrompt.userChoice;
  console.log(`User ${outcome} the install prompt`);

  deferredPrompt = null;
  hideInstallButton();
}

function hideInstallButton(): void {
  const installButton = document.getElementById('install-button');
  if (installButton) {
    installButton.style.display = 'none';
  }
}

// Detect installation complete
window.addEventListener('appinstalled', () => {
  console.log('PWA was installed');
  hideInstallButton();
});

Background Sync

// sw.js
self.addEventListener('sync', (event: SyncEvent) => {
  if (event.tag === 'sync-messages') {
    event.waitUntil(syncMessages());
  }
});

async function syncMessages(): Promise<void> {
  // Get data saved offline from IndexedDB
  const pendingMessages = await getPendingMessages();

  for (const message of pendingMessages) {
    try {
      await fetch('/api/messages', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(message),
      });
      await removePendingMessage(message.id);
    } catch (error) {
      console.error('Sync failed:', error);
      throw error; // Trigger retry
    }
  }
}

// Register sync on client side
async function registerBackgroundSync(): Promise<void> {
  const registration = await navigator.serviceWorker.ready;

  if ('sync' in registration) {
    await registration.sync.register('sync-messages');
    console.log('Background sync registered');
  }
}

Workbox (Service Worker Simplification Library)

// sw.ts (using Workbox)
import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
import { CacheableResponsePlugin } from 'workbox-cacheable-response';

// Precache manifest generated at build time
precacheAndRoute(self.__WB_MANIFEST);

// Image caching
registerRoute(
  ({ request }) => request.destination === 'image',
  new CacheFirst({
    cacheName: 'images',
    plugins: [
      new CacheableResponsePlugin({ statuses: [0, 200] }),
      new ExpirationPlugin({
        maxEntries: 50,
        maxAgeSeconds: 30 * 24 * 60 * 60, // 30 days
      }),
    ],
  })
);

// API requests
registerRoute(
  ({ url }) => url.pathname.startsWith('/api/'),
  new NetworkFirst({
    cacheName: 'api',
    networkTimeoutSeconds: 10,
    plugins: [
      new CacheableResponsePlugin({ statuses: [0, 200] }),
      new ExpirationPlugin({
        maxEntries: 100,
        maxAgeSeconds: 24 * 60 * 60, // 1 day
      }),
    ],
  })
);

// Google Fonts caching
registerRoute(
  ({ url }) => url.origin === 'https://fonts.googleapis.com',
  new StaleWhileRevalidate({
    cacheName: 'google-fonts-stylesheets',
  })
);

PWA Checklist

CategoryCheck Item
RequiredHTTPS support
RequiredService Worker registration
RequiredWeb App Manifest
RecommendedOffline fallback
RecommendedIcon 192x192 or larger
RecommendedTheme color setting
OptionalPush notifications
OptionalBackground sync
← Back to list