← Back to Guides

Building Progressive Web Apps (PWA)

📖 15 min read | 📅 Updated: January 2025 | 🏷️ Web Development

What is a Progressive Web App?

Progressive Web Apps (PWAs) combine the best of web and mobile apps. They're web applications that use modern web capabilities to deliver an app-like experience to users. PWAs are reliable, fast, and engaging, working offline and loading instantly regardless of network conditions.

Key Features of PWAs

1. Web App Manifest

The web app manifest is a JSON file that tells the browser about your PWA and how it should behave when installed.

manifest.json Example

{
  "name": "My Amazing PWA",
  "short_name": "PWA",
  "description": "A progressive web app example",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#2196F3",
  "orientation": "portrait-primary",
  "icons": [
    {
      "src": "/icons/icon-72x72.png",
      "sizes": "72x72",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "any maskable"
    }
  ]
}

Link Manifest in HTML

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

2. Service Workers

Service workers are the core technology behind PWAs. They're JavaScript files that run in the background, enabling features like offline support, background sync, and push notifications.

Registering a Service Worker

// In your main JavaScript file
if ('serviceWorker' in navigator) {
  window.addEventListener('load', () => {
    navigator.serviceWorker.register('/service-worker.js')
      .then(registration => {
        console.log('SW registered:', registration);
      })
      .catch(error => {
        console.log('SW registration failed:', error);
      });
  });
}

Basic Service Worker (service-worker.js)

const CACHE_NAME = 'my-pwa-cache-v1';
const urlsToCache = [
  '/',
  '/styles/main.css',
  '/script/main.js',
  '/images/logo.png'
];

// Install event - cache resources
self.addEventListener('install', event => {
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then(cache => {
        console.log('Opened cache');
        return cache.addAll(urlsToCache);
      })
  );
});

// Fetch event - serve from cache
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 old caches
self.addEventListener('activate', event => {
  const cacheWhitelist = [CACHE_NAME];
  event.waitUntil(
    caches.keys().then(cacheNames => {
      return Promise.all(
        cacheNames.map(cacheName => {
          if (cacheWhitelist.indexOf(cacheName) === -1) {
            return caches.delete(cacheName);
          }
        })
      );
    })
  );
});

3. Caching Strategies

Different resources require different caching strategies:

Cache First (Best for static assets)

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

Network First (Best for dynamic data)

self.addEventListener('fetch', event => {
  event.respondWith(
    fetch(event.request)
      .catch(() => caches.match(event.request))
  );
});

Stale While Revalidate

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

4. Offline Functionality

Create an offline fallback page for better UX:

const OFFLINE_URL = '/offline.html';

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

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

5. Push Notifications

Engage users with push notifications even when they're not on your site:

Request Permission

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

Show Notification in Service Worker

self.addEventListener('push', event => {
  const data = event.data.json();
  
  const options = {
    body: data.body,
    icon: '/images/icon.png',
    badge: '/images/badge.png',
    vibrate: [200, 100, 200],
    data: { url: data.url }
  };
  
  event.waitUntil(
    self.registration.showNotification(data.title, options)
  );
});

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

6. Background Sync

Defer actions until the user has stable connectivity:

// Register sync in your app
navigator.serviceWorker.ready.then(registration => {
  return registration.sync.register('sync-messages');
});

// Handle sync in service worker
self.addEventListener('sync', event => {
  if (event.tag === 'sync-messages') {
    event.waitUntil(sendMessages());
  }
});

async function sendMessages() {
  const db = await openDB();
  const messages = await db.getAll('outbox');
  
  for (const message of messages) {
    try {
      await fetch('/api/messages', {
        method: 'POST',
        body: JSON.stringify(message)
      });
      await db.delete('outbox', message.id);
    } catch (error) {
      console.error('Failed to send:', error);
    }
  }
}

7. Installation Prompt

Encourage users to install your PWA:

let deferredPrompt;

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

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

8. Testing Your PWA

Use these tools to test your PWA:

💡 Best Practices:

Conclusion

Progressive Web Apps represent the future of web development, combining the reach of the web with the capabilities of native apps. By implementing service workers, offline support, and installability, you can create engaging, reliable experiences that users love.

Start small, add PWA features incrementally, and use analytics to measure the impact on user engagement and retention. Your users will appreciate the improved experience, especially on mobile devices and slow networks.