PWA Complete Guide: Building Web Apps That Feel Native

Master the core technologies of Progressive Web Apps including Service Workers, caching strategies, and offline experiences

PWA Complete Guide: Building Web Apps That Feel Native

Progressive Web Apps (PWA) represent the future of web applications—combining the openness of the web with the user experience of native apps. This article takes you through mastering the core technologies of PWA.

What is a PWA?

Core Characteristics of PWA

The Three Pillars of PWA:
┌─────────────────────────────────────────────────────┐
│                                                     │
│   Reliable                                          │
│   ├─ Works offline                                  │
│   ├─ Functions on poor networks                    │
│   └─ Resource caching ensures fast loading          │
│                                                     │
│   Fast                                              │
│   ├─ Loads in seconds                               │
│   ├─ Smooth interaction animations                  │
│   └─ Instant response to user actions               │
│                                                     │
│   Engaging                                          │
│   ├─ Installable to home screen                     │
│   ├─ Runs fullscreen                                │
│   └─ Push notifications                             │
│                                                     │
└─────────────────────────────────────────────────────┘

PWA vs Native Apps vs Traditional Web

FeatureTraditional WebPWANative App
InstallationNot requiredOptionalRequired
Offline
Push Notifications
Device APIsLimitedPartialFull
UpdatesAutomaticAutomaticDownload required
DistributionURLURLApp Store
Development CostLowLowHigh

Web App Manifest

The Manifest is the PWA’s identity card, defining the app’s appearance and behavior.

Basic Configuration

// manifest.json
{
  "name": "My Progressive Web App",
  "short_name": "MyPWA",
  "description": "A sample PWA application",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#3b82f6",
  "orientation": "portrait-primary",
  "icons": [
    {
      "src": "/icons/icon-192.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "any maskable"
    },
    {
      "src": "/icons/icon-512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "any maskable"
    }
  ]
}

Referencing in HTML

<!DOCTYPE html>
<html>
<head>
  <!-- Manifest -->
  <link rel="manifest" href="/manifest.json">

  <!-- iOS 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-192.png">

  <!-- Theme Color -->
  <meta name="theme-color" content="#3b82f6">
</head>
</html>

Display Modes Explained

Four display modes:
┌─────────────────────────────────────────────────────┐
│ fullscreen                                          │
│ └─ Complete fullscreen, hides all browser UI        │
│                                                     │
│ standalone                                          │
│ └─ Like native app, has status bar but no browser UI│
│                                                     │
│ minimal-ui                                          │
│ └─ Has minimal browser controls (like back button)  │
│                                                     │
│ browser                                             │
│ └─ Normal browser tab (default)                     │
└─────────────────────────────────────────────────────┘

Service Worker: The Heart of PWA

Service Worker is a script running in the browser background—it’s the key to offline functionality.

Lifecycle

Service Worker Lifecycle:
┌─────────────────────────────────────────────────────┐
│                                                     │
│   Register                                          │
│       ↓                                             │
│   Install ──→ Precache resources                    │
│       ↓                                             │
│   Waiting ──→ Wait for old SW to release control    │
│       ↓                                             │
│   Activate ──→ Clean up old caches                  │
│       ↓                                             │
│   Controlling ──→ Intercept network requests        │
│       ↓                                             │
│   Terminated ──→ Reclaimed by browser when idle     │
│                                                     │
└─────────────────────────────────────────────────────┘

Registering Service Worker

// main.js
if ('serviceWorker' in navigator) {
  window.addEventListener('load', async () => {
    try {
      const registration = await navigator.serviceWorker.register('/sw.js', {
        scope: '/'
      });

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

      // Check for updates
      registration.addEventListener('updatefound', () => {
        const newWorker = registration.installing;
        newWorker.addEventListener('statechange', () => {
          if (newWorker.state === 'installed') {
            if (navigator.serviceWorker.controller) {
              // New version available, prompt user to refresh
              showUpdateNotification();
            }
          }
        });
      });
    } catch (error) {
      console.error('SW registration failed:', error);
    }
  });
}

Service Worker Basic Structure

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

// Install event: precache resources
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then((cache) => cache.addAll(ASSETS_TO_CACHE))
      .then(() => self.skipWaiting())  // Activate immediately
  );
});

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

// Intercept requests
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request)
      .then((response) => response || fetch(event.request))
  );
});

Caching Strategies

Different types of resources need different caching strategies.

Common Strategies Comparison

Caching Strategy Matrix:
┌────────────────────┬────────────────────────────────┐
│ Strategy           │ Best For                       │
├────────────────────┼────────────────────────────────┤
│ Cache First        │ Static assets, fonts, images   │
│ Network First      │ API requests, real-time data   │
│ Stale While        │ Content that can be briefly    │
│ Revalidate         │ stale                          │
│ Cache Only         │ Offline page, app shell        │
│ Network Only       │ Must be real-time (payments)   │
└────────────────────┴────────────────────────────────┘

Cache First

// Best for static resources
self.addEventListener('fetch', (event) => {
  if (event.request.destination === 'image') {
    event.respondWith(
      caches.match(event.request).then((cachedResponse) => {
        if (cachedResponse) {
          return cachedResponse;
        }

        return fetch(event.request).then((networkResponse) => {
          const responseClone = networkResponse.clone();
          caches.open(CACHE_NAME).then((cache) => {
            cache.put(event.request, responseClone);
          });
          return networkResponse;
        });
      })
    );
  }
});

Network First

// Best for API requests
async function networkFirst(request, cacheName) {
  const cache = await caches.open(cacheName);

  try {
    const networkResponse = await fetch(request);
    // Update cache on success
    cache.put(request, networkResponse.clone());
    return networkResponse;
  } catch (error) {
    // Fall back to cache on failure
    const cachedResponse = await cache.match(request);
    if (cachedResponse) {
      return cachedResponse;
    }
    throw error;
  }
}

Stale While Revalidate

// Best for content that doesn't need immediate updates
async function staleWhileRevalidate(request, cacheName) {
  const cache = await caches.open(cacheName);
  const cachedResponse = await cache.match(request);

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

  // Return cache immediately (if available)
  return cachedResponse || fetchPromise;
}

Complete Routing Strategy

// sw.js
self.addEventListener('fetch', (event) => {
  const { request } = event;
  const url = new URL(request.url);

  // API requests: Network First
  if (url.pathname.startsWith('/api/')) {
    event.respondWith(networkFirst(request, 'api-cache'));
    return;
  }

  // Static assets: Cache First
  if (request.destination === 'image' ||
      request.destination === 'font' ||
      request.destination === 'style') {
    event.respondWith(cacheFirst(request, 'static-cache'));
    return;
  }

  // HTML pages: SWR
  if (request.mode === 'navigate') {
    event.respondWith(
      staleWhileRevalidate(request, 'pages-cache')
        .catch(() => caches.match('/offline.html'))
    );
    return;
  }

  // Default: Network First
  event.respondWith(networkFirst(request, 'default-cache'));
});

Offline Experience

Offline Page

<!-- offline.html -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Offline - MyPWA</title>
  <style>
    body {
      display: flex;
      flex-direction: column;
      align-items: center;
      justify-content: center;
      min-height: 100vh;
      font-family: system-ui;
      background: #f3f4f6;
    }
    .icon { font-size: 4rem; margin-bottom: 1rem; }
    h1 { color: #374151; margin-bottom: 0.5rem; }
    p { color: #6b7280; }
    button {
      margin-top: 1rem;
      padding: 0.75rem 1.5rem;
      background: #3b82f6;
      color: white;
      border: none;
      border-radius: 0.5rem;
      cursor: pointer;
    }
  </style>
</head>
<body>
  <div class="icon">📡</div>
  <h1>You're Currently Offline</h1>
  <p>Please check your network connection and try again</p>
  <button onclick="location.reload()">Retry</button>
</body>
</html>

Detecting Online Status

// Listen for online/offline status changes
window.addEventListener('online', () => {
  showToast('Network restored');
  // Sync offline data
  syncOfflineData();
});

window.addEventListener('offline', () => {
  showToast('You are offline, some features may be limited');
});

// Check current status
function isOnline() {
  return navigator.onLine;
}

Offline Data Sync

// Using Background Sync API
async function saveDataOffline(data) {
  // Store in IndexedDB
  await db.pendingRequests.add({
    url: '/api/data',
    method: 'POST',
    body: data,
    timestamp: Date.now()
  });

  // Register background sync
  if ('serviceWorker' in navigator && 'sync' in window.ServiceWorkerRegistration.prototype) {
    const registration = await navigator.serviceWorker.ready;
    await registration.sync.register('sync-data');
  }
}

// Handle sync in sw.js
self.addEventListener('sync', (event) => {
  if (event.tag === 'sync-data') {
    event.waitUntil(syncPendingRequests());
  }
});

async function syncPendingRequests() {
  const db = await openDB();
  const requests = await db.pendingRequests.getAll();

  for (const request of requests) {
    try {
      await fetch(request.url, {
        method: request.method,
        body: JSON.stringify(request.body)
      });
      await db.pendingRequests.delete(request.id);
    } catch (error) {
      console.error('Sync failed:', error);
    }
  }
}

Push Notifications

Requesting Permission

async function requestNotificationPermission() {
  if (!('Notification' in window)) {
    console.log('Browser does not support notifications');
    return false;
  }

  if (Notification.permission === 'granted') {
    return true;
  }

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

  return false;
}

Subscribing to Push

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

  // Get push subscription
  let subscription = await registration.pushManager.getSubscription();

  if (!subscription) {
    // Create new subscription
    const vapidPublicKey = 'YOUR_VAPID_PUBLIC_KEY';
    subscription = await registration.pushManager.subscribe({
      userVisibleOnly: true,
      applicationServerKey: urlBase64ToUint8Array(vapidPublicKey)
    });
  }

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

  return subscription;
}

// Helper function
function urlBase64ToUint8Array(base64String) {
  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)));
}

Handling Push Messages

// sw.js
self.addEventListener('push', (event) => {
  const data = event.data?.json() ?? {
    title: 'New Message',
    body: 'You have a new notification',
    icon: '/icons/icon-192.png'
  };

  event.waitUntil(
    self.registration.showNotification(data.title, {
      body: data.body,
      icon: data.icon,
      badge: '/icons/badge.png',
      vibrate: [200, 100, 200],
      data: data.url,
      actions: [
        { action: 'open', title: 'View' },
        { action: 'close', title: 'Close' }
      ]
    })
  );
});

// Handle notification click
self.addEventListener('notificationclick', (event) => {
  event.notification.close();

  if (event.action === 'open' || !event.action) {
    event.waitUntil(
      clients.openWindow(event.notification.data || '/')
    );
  }
});

App Installation

Detecting Installation State

let deferredPrompt;

window.addEventListener('beforeinstallprompt', (event) => {
  // Prevent default install prompt
  event.preventDefault();
  // Save event for later use
  deferredPrompt = event;
  // Show custom install button
  showInstallButton();
});

window.addEventListener('appinstalled', () => {
  console.log('PWA installed');
  hideInstallButton();
  deferredPrompt = null;
});

Triggering Installation

async function installApp() {
  if (!deferredPrompt) {
    return;
  }

  // Show install prompt
  deferredPrompt.prompt();

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

  if (outcome === 'accepted') {
    console.log('User accepted installation');
  } else {
    console.log('User dismissed installation');
  }

  deferredPrompt = null;
}

Custom Installation UI

<div id="install-prompt" class="install-prompt" hidden>
  <div class="install-content">
    <img src="/icons/icon-192.png" alt="App Icon" width="64">
    <div>
      <h3>Install MyPWA</h3>
      <p>Install to home screen for a better experience</p>
    </div>
  </div>
  <div class="install-actions">
    <button onclick="dismissInstall()">Later</button>
    <button onclick="installApp()" class="primary">Install</button>
  </div>
</div>

Using Workbox

Workbox is Google’s Service Worker toolkit that greatly simplifies PWA development.

Installation and Configuration

npm install workbox-webpack-plugin
# or
npm install workbox-cli

Using Workbox

// sw.js
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 (injected by build tool)
precacheAndRoute(self.__WB_MANIFEST);

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

// API caching strategy
registerRoute(
  ({ url }) => url.pathname.startsWith('/api/'),
  new NetworkFirst({
    cacheName: 'api-responses',
    networkTimeoutSeconds: 3,
    plugins: [
      new ExpirationPlugin({
        maxEntries: 50,
        maxAgeSeconds: 5 * 60  // 5 minutes
      })
    ]
  })
);

// Page caching strategy
registerRoute(
  ({ request }) => request.mode === 'navigate',
  new StaleWhileRevalidate({
    cacheName: 'pages'
  })
);

Workbox with Vite

// vite.config.js
import { VitePWA } from 'vite-plugin-pwa';

export default {
  plugins: [
    VitePWA({
      registerType: 'autoUpdate',
      includeAssets: ['favicon.ico', 'robots.txt', 'apple-touch-icon.png'],
      manifest: {
        name: 'My PWA',
        short_name: 'MyPWA',
        theme_color: '#3b82f6',
        icons: [
          {
            src: 'pwa-192x192.png',
            sizes: '192x192',
            type: 'image/png'
          },
          {
            src: 'pwa-512x512.png',
            sizes: '512x512',
            type: 'image/png'
          }
        ]
      },
      workbox: {
        globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
        runtimeCaching: [
          {
            urlPattern: /^https:\/\/api\.example\.com\/.*/i,
            handler: 'NetworkFirst',
            options: {
              cacheName: 'api-cache',
              expiration: {
                maxEntries: 10,
                maxAgeSeconds: 60 * 60 * 24  // 24 hours
              }
            }
          }
        ]
      }
    })
  ]
};

Performance Optimization

App Shell Architecture

App Shell Pattern:
┌─────────────────────────────────────────────────────┐
│  Header (cached)                                    │
├─────────────────────────────────────────────────────┤
│  Navigation (cached)                                │
├─────────────────────────────────────────────────────┤
│                                                     │
│   ┌─────────────────────────────────────────────┐   │
│   │                                             │   │
│   │       Dynamic Content (network request)     │   │
│   │                                             │   │
│   └─────────────────────────────────────────────┘   │
│                                                     │
├─────────────────────────────────────────────────────┤
│  Footer (cached)                                    │
└─────────────────────────────────────────────────────┘

First load: Only need to load Shell (very small)
Subsequent visits: Shell loads instantly from cache,
                   content loads in parallel

Preloading Critical Resources

<head>
  <!-- Preload critical resources -->
  <link rel="preload" href="/fonts/main.woff2" as="font" type="font/woff2" crossorigin>
  <link rel="preload" href="/styles/critical.css" as="style">
  <link rel="preload" href="/scripts/app.js" as="script">

  <!-- Preconnect to API server -->
  <link rel="preconnect" href="https://api.example.com">
  <link rel="dns-prefetch" href="https://api.example.com">
</head>

Debugging Tips

Chrome DevTools

Application Panel Features:
┌─────────────────────────────────────────────────────┐
│ Service Workers                                     │
│ ├─ View registered SWs                              │
│ ├─ Update, skip waiting, unregister                 │
│ └─ Simulate offline mode                            │
│                                                     │
│ Cache Storage                                       │
│ ├─ View all caches                                  │
│ ├─ Preview cached content                           │
│ └─ Delete caches                                    │
│                                                     │
│ Manifest                                            │
│ ├─ Validate manifest configuration                  │
│ └─ Test install functionality                       │
└─────────────────────────────────────────────────────┘

Lighthouse Audit

# Using Lighthouse CLI
npm install -g lighthouse
lighthouse https://your-pwa.com --only-categories=pwa

# Report includes:
# - Installability checks
# - PWA optimization checklist
# - Service Worker verification
# - Offline functionality tests

Summary

PWA gives web apps a native experience:

TechnologyPurpose
ManifestDefine app appearance and installation behavior
Service WorkerIntercept requests, cache resources, offline support
Cache APIFine-grained caching control
Push APIPush notifications
Background SyncOffline data synchronization
WorkboxSimplify SW development

Key Takeaways:

  1. PWA is progressive—you can incrementally enhance existing apps
  2. Service Worker is core—mastering its lifecycle is crucial
  3. Caching strategies must be tailored—different resources need different strategies
  4. Offline experience needs careful design—not just showing an error page
  5. Workbox greatly simplifies development—recommended for production

PWA isn’t about replacing native apps—it’s about reaching more users with web apps and providing better experiences.


The future of the web is progressive—PWA enables every website to become an app.