Skip to content
All essays
WebFebruary 28, 202411 min

PWA Development Complete Guide

Build Progressive Web Apps that work offline, install on devices, and provide native app-like experiences.

Ü
Ümit Uz
Mobile & Full Stack Developer

Progressive Web Apps blur the line between web and native. Here's how to build PWAs that users love.

What Makes a PWA?

A Progressive Web App requires:

  • HTTPS: Secure connection required
  • Service Worker: Enables offline functionality
  • Web App Manifest: Controls installation and appearance
  • Responsive Design: Works on all devices
  • Safe Browsing: No malicious content

Service Worker Fundamentals

Registration

Register your service worker.

javascript
// public/sw.js
const CACHE_NAME = 'v1';
const ASSETS = [
  '/',
  '/styles.css',
  '/app.js',
  '/offline.html',
];

// Install event - cache assets
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => {
      return cache.addAll(ASSETS);
    })
  );
});

// Activate event - clean old caches
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((keys) => {
      return Promise.all(
        keys
          .filter((key) => key !== CACHE_NAME)
          .map((key) => caches.delete(key))
      );
    })
  );
});

// Fetch event - serve from cache, fallback to network
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((response) => {
      return response || fetch(event.request);
    })
  );
});

Register in App

javascript
// src/index.js
if ('serviceWorker' in navigator) {
  window.addEventListener('load', () => {
    navigator.serviceWorker
      .register('/sw.js')
      .then((registration) => {
        console.log('SW registered:', registration);
      })
      .catch((error) => {
        console.log('SW registration failed:', error);
      });
  });
}

Caching Strategies

Cache First

Best for static assets.

javascript
self.addEventListener('fetch', (event) => {
  // Cache first, fallback to network
  if (event.request.url.includes('/static/')) {
    event.respondWith(
      caches.match(event.request).then((response) => {
        return response || fetch(event.request);
      })
    );
  }
});

Network First

Best for API calls where fresh data matters.

javascript
self.addEventListener('fetch', (event) => {
  if (event.request.url.includes('/api/')) {
    event.respondWith(
      fetch(event.request)
        .then((response) => {
          // Cache successful responses
          if (response.status === 200) {
            const responseClone = response.clone();
            caches.open('api-cache').then((cache) => {
              cache.put(event.request, responseClone);
            });
          }
          return response;
        })
        .catch(() => {
          // Fallback to cache
          return caches.match(event.request);
        })
    );
  }
});

Stale While Revalidate

Balance speed and freshness.

javascript
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.open('dynamic-cache').then((cache) => {
      return cache.match(event.request).then((response) => {
        // Fetch in background
        const fetchPromise = fetch(event.request).then((networkResponse) => {
          cache.put(event.request, networkResponse.clone());
          return networkResponse;
        });

        // Return cached version immediately
        return response || fetchPromise;
      });
    })
  );
});

Web App Manifest

Basic Manifest

json
{
  "name": "My PWA",
  "short_name": "PWA",
  "description": "A progressive web app",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#3b82f6",
  "orientation": "portrait-primary",
  "icons": [
    {
      "src": "/icons/icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "any maskable"
    },
    {
      "src": "/icons/icon-512x512.png",
      "_sizes": "512x512",
      "type": "image/png",
      "purpose": "any maskable"
    }
  ]
}
html
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#3b82f6">
<link rel="apple-touch-icon" href="/icons/icon-192x192.png">

Installation

Detect Installability

javascript
// Track install prompt
let deferredPrompt;

window.addEventListener('beforeinstallprompt', (e) => {
  e.preventDefault();
  deferredPrompt = e;

  // Show install button
  installButton.style.display = 'block';
});

installButton.addEventListener('click', async () => {
  if (deferredPrompt) {
    deferredPrompt.prompt();
    const { outcome } = await deferredPrompt.userChoice;

    if (outcome === 'accepted') {
      console.log('User accepted install');
    }

    deferredPrompt = null;
  }
});

// Detect if app is already installed
window.addEventListener('appinstalled', () => {
  installButton.style.display = 'none';
});

Offline Experience

Offline Page

html
<!-- offline.html -->
<!DOCTYPE html>
<html>
<head>
  <title>You're Offline</title>
  <style>
    body {
      display: flex;
      justify-content: center;
      align-items: center;
      height: 100vh;
      font-family: system-ui;
      text-align: center;
    }
  </style>
</head>
<body>
  <div>
    <h1>You're Offline</h1>
    <p>Please check your internet connection.</p>
    <button onclick="location.reload()">Try Again</button>
  </div>
</body>
</html>

Offline Detection

javascript
// Monitor online/offline status
window.addEventListener('online', () => {
  showNotification('You're back online!');
});

window.addEventListener('offline', () => {
  showNotification('You're offline. Some features may be unavailable.');
});

// Check current status
if (navigator.onLine) {
  console.log('Online');
} else {
  console.log('Offline');
}

Push Notifications

Request Permission

javascript
async function requestNotificationPermission() {
  const permission = await Notification.requestPermission();

  if (permission === 'granted') {
    console.log('Notification permission granted');
    // Subscribe to push
    subscribeToPush();
  } else {
    console.log('Notification permission denied');
  }
}

Subscribe to Push

javascript
async function subscribeToPush() {
  try {
    const registration = await navigator.serviceWorker.ready;
    const subscription = await registration.pushManager.subscribe({
      userVisibleOnly: true,
      applicationServerKey: 'YOUR_VAPID_PUBLIC_KEY',
    });

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

Handle Push Events

javascript
// In service worker
self.addEventListener('push', (event) => {
  const options = {
    body: event.data.text(),
    icon: '/icons/icon-192x192.png',
    badge: '/icons/badge-72x72.png',
    vibrate: [200, 100, 200],
    data: {
      url: '/',
    },
  };

  event.waitUntil(
    self.registration.showNotification('New Message', options)
  );
});

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

  event.waitUntil(
    clients.openWindow(event.notification.data.url)
  );
});

Background Sync

Register Sync

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

  await registration.sync.register('sync-data');

  // Handle sync in service worker
}

// In service worker
self.addEventListener('sync', (event) => {
  if (event.tag === 'sync-data') {
    event.waitUntil(syncData());
  }
});

async function syncData() {
  // Get data from IndexedDB
  const data = await getDataFromIndexedDB();

  // Send to server
  await fetch('/api/sync', {
    method: 'POST',
    body: JSON.stringify(data),
  });
}

Performance

App Shell Model

Cache shell immediately, load content dynamically.

javascript
// Cache app shell
const APP_SHELL = [
  '/',
  '/app.css',
  '/app.js',
  '/icons/',
];

self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open('shell-v1').then((cache) => {
      return cache.addAll(APP_SHELL);
    })
  );
});

// Serve shell from cache, content from network
self.addEventListener('fetch', (event) => {
  if (APP_SHELL.some(url => event.request.url.includes(url))) {
    event.respondWith(
      caches.match(event.request).then((response) => {
        return response || fetch(event.request);
      })
    );
  } else {
    event.respondWith(fetch(event.request));
  }
});

Testing

Lighthouse PWA Audit

bash
# Run Lighthouse
lighthouse https://your-pwa.com --view

Check:

  • Progressive Web App score: 100
  • Performance: 90+
  • Accessibility: 100
  • Best Practices: 90+

Manual Testing

Test on real devices:

  1. 1Load app with network enabled
  2. 2Enable airplane mode
  3. 3Navigate app - should work offline
  4. 4Disable airplane mode
  5. 5Verify app syncs data

Best Practices

DO

  • Implement proper caching strategies
  • Provide meaningful offline experience
  • Request permissions at appropriate times
  • Test on real devices
  • Monitor performance
  • Keep service worker simple

DON'T

  • Cache everything indiscriminately
  • Ignore offline scenarios
  • Spam notifications
  • Forget HTTPS
  • Skip testing
  • Overcomplicate service worker

Tools

Workbox

Google's library for building service workers.

javascript
// Generate service worker with Workbox
import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute, NetworkFirst } from 'workbox-routing';
import { NetworkOnly } from 'workbox-strategies';

// Precache assets
precacheAndRoute(self.__WB_MANIFEST);

// API routes - network first
registerRoute(
  ({ url }) => url.pathname.startsWith('/api/'),
  new NetworkFirst()
);

PWA Builder

Generate manifests and icons.

bash
# Install PWA Builder
npm install -g pwabuilder

# Generate assets
pwabuilder manifest

Conclusion

PWAs provide native app-like experiences on the web. Implement service workers for offline functionality, create proper manifests for installation, and use appropriate caching strategies. Test thoroughly on real devices and monitor performance. The investment pays off in better user experience and engagement.

Related essays

Next essay
Web Vitals Optimization