Building Progressive Web Apps: A Complete Guide for 2025

Building Progressive Web Apps: A Complete Guide for 2025

Progressive Web Apps (PWAs) are the future of mobile web experiences. They're fast, reliable, work offline, and can be installed on users' devices—all without app stores or downloads.

What Makes a PWA?

A Progressive Web App must be:

  1. Fast - Loads instantly, even on slow networks
  2. Reliable - Works offline or on poor connections
  3. Engaging - Feels like a native app
  4. Installable - Can be added to home screen
  5. Discoverable - Indexed by search engines
  6. Safe - Served over HTTPS

The Core Components

1. Service Worker

The heart of any PWA. It's a script that runs in the background, separate from your web page:

// sw.js
const CACHE_NAME = 'my-app-v1';
const urlsToCache = [
  '/',
  '/styles/main.css',
  '/scripts/main.js',
  '/images/logo.png'
];

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

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

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

Register your service worker:

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

2. Web App Manifest

Defines how your app appears when installed:

{
  "name": "My Awesome App",
  "short_name": "MyApp",
  "description": "An awesome progressive web app",
  "start_url": "/",
  "display": "standalone",
  "theme_color": "#6366f1",
  "background_color": "#ffffff",
  "orientation": "portrait-primary",
  "icons": [
    {
      "src": "/icons/icon-72x72.png",
      "sizes": "72x72",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "maskable"
    },
    {
      "src": "/icons/icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png"
    }
  ]
}

Link it in your HTML:

<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#6366f1">

Caching Strategies

Cache First

Best for static assets that rarely change:

self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((cached) => {
      return cached || fetch(event.request);
    })
  );
});

Network First

Best for API calls and dynamic content:

self.addEventListener('fetch', (event) => {
  event.respondWith(
    fetch(event.request)
      .then((response) => {
        const responseClone = response.clone();
        caches.open(CACHE_NAME).then((cache) => {
          cache.put(event.request, responseClone);
        });
        return response;
      })
      .catch(() => caches.match(event.request))
  );
});

Stale While Revalidate

Best for content that updates frequently but stale is okay:

self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((cached) => {
      const fetchPromise = fetch(event.request).then((response) => {
        caches.open(CACHE_NAME).then((cache) => {
          cache.put(event.request, response.clone());
        });
        return response;
      });
      return cached || fetchPromise;
    })
  );
});

Offline Support

Offline Page

Show a custom offline page:

const OFFLINE_URL = '/offline.html';

self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => {
      return cache.add(OFFLINE_URL);
    })
  );
});

self.addEventListener('fetch', (event) => {
  if (event.request.mode === 'navigate') {
    event.respondWith(
      fetch(event.request).catch(() => {
        return caches.match(OFFLINE_URL);
      })
    );
  }
});

Background Sync

Retry failed requests when connection returns:

// In your app
navigator.serviceWorker.ready.then((registration) => {
  registration.sync.register('sync-posts');
});

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

async function syncPosts() {
  const pendingPosts = await getPendingPosts();
  return Promise.all(
    pendingPosts.map((post) => fetch('/api/posts', {
      method: 'POST',
      body: JSON.stringify(post)
    }))
  );
}

Push Notifications

Engage users with timely updates:

// Request permission
async function subscribeToPush() {
  const registration = await navigator.serviceWorker.ready;
  
  const subscription = await registration.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: urlBase64ToUint8Array(publicVapidKey)
  });
  
  // Send subscription to server
  await fetch('/api/subscribe', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(subscription)
  });
}

// Handle push events in service worker
self.addEventListener('push', (event) => {
  const data = event.data.json();
  
  event.waitUntil(
    self.registration.showNotification(data.title, {
      body: data.body,
      icon: '/icons/icon-192x192.png',
      badge: '/icons/badge-72x72.png',
      data: { url: data.url }
    })
  );
});

// Handle notification clicks
self.addEventListener('notificationclick', (event) => {
  event.notification.close();
  event.waitUntil(
    clients.openWindow(event.notification.data.url)
  );
});

Install Prompt

Encourage users to install your PWA:

let deferredPrompt;

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

async function showInstallPromotion() {
  const installButton = document.getElementById('install-button');
  installButton.style.display = 'block';
  
  installButton.addEventListener('click', async () => {
    installButton.style.display = 'none';
    deferredPrompt.prompt();
    const { outcome } = await deferredPrompt.userChoice;
    console.log(`User response: ${outcome}`);
    deferredPrompt = null;
  });
}

Testing Your PWA

Chrome DevTools

  1. Open DevTools
  2. Go to "Application" tab
  3. Check "Service Workers"
  4. Test offline mode
  5. Validate manifest

Lighthouse

Run Lighthouse audit for PWA compliance:

npm install -g lighthouse
lighthouse https://your-pwa.com --view

Target scores:

  • Performance: > 90
  • Accessibility: > 90
  • Best Practices: > 90
  • SEO: > 90
  • PWA: 100

Using a Framework

Workbox (Google)

Simplifies service worker development:

import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { StaleWhileRevalidate, CacheFirst } from 'workbox-strategies';

// Precache build files
precacheAndRoute(self.__WB_MANIFEST);

// Cache images
registerRoute(
  ({ request }) => request.destination === 'image',
  new CacheFirst({
    cacheName: 'images',
    plugins: [
      new ExpirationPlugin({
        maxEntries: 60,
        maxAgeSeconds: 30 * 24 * 60 * 60, // 30 days
      }),
    ],
  })
);

// Cache API responses
registerRoute(
  ({ url }) => url.pathname.startsWith('/api/'),
  new StaleWhileRevalidate({
    cacheName: 'api-cache',
  })
);

Next.js PWA

// next.config.js
const withPWA = require('next-pwa')({
  dest: 'public',
  register: true,
  skipWaiting: true,
});

module.exports = withPWA({
  // your Next.js config
});

Best Practices

  1. Start with HTTPS - PWAs require secure connections
  2. Keep it lightweight - Don't cache everything
  3. Update regularly - Implement versioning for service workers
  4. Test offline - Ensure core functionality works without network
  5. Monitor performance - Use analytics to track PWA metrics
  6. Graceful degradation - Work on browsers without full PWA support

Real-World Examples

Great PWAs to study:

The Future is Progressive

PWAs are becoming more capable every year. With features like file system access, Bluetooth, USB, and more, the gap between web and native apps continues to shrink.

Ready to build your PWA? Let's talk.