PWA PROGRESSIVE • INSTALLABLE • OFFLINE
Frontend

Building Progressive Web Apps (PWA)

Mayur Dabhi
Mayur Dabhi
March 28, 2026
24 min read

A user opens your web app on a train, the signal drops, and they get the browser's offline dinosaur instead of the order they were halfway through. Or a client asks for "an app" and you know that means two native codebases, two store reviews, and a release cycle you don't control. A Progressive Web App (PWA) is the middle ground: a normal website, built with HTML, CSS and JavaScript, that can be installed, keeps working offline, and can send push notifications.

This post goes through the pieces you actually need to ship one: a service worker, a manifest, a caching strategy per content type, the install prompt, push, background sync, Workbox, and how to test it all.

What's covered
  • What "progressive" means and how a PWA is put together
  • Implementing Service Workers for offline functionality
  • Creating a Web App Manifest for installability
  • Picking a caching strategy (Cache First, Network First, Stale-While-Revalidate)
  • Implementing push notifications
  • Background sync and periodic background sync
  • Using Workbox to simplify PWA development
  • Testing and auditing with DevTools and Lighthouse

What Makes an App "Progressive"?

"Progressive" comes from progressive enhancement. The site has to work in any browser, and then you add offline support, installability and push on top wherever the browser supports them. If a feature isn't there, the user just gets a regular website, not a broken one.

PWA Core Pillars ⚡ RELIABLE Loads instantly Works offline Never shows downasaur Cached resources 🚀 FAST Smooth animations Quick interactions No jank or lag Optimized assets 💡 ENGAGING Installable Push notifications Full-screen mode Home screen icon

The three qualities people usually mean when they say "PWA"

PWA vs Native Apps vs Traditional Web

Feature Traditional Web PWA Native App
Installation Not needed Optional, instant Required via app store
Offline Support ❌ No ✅ Yes ✅ Yes
Push Notifications ❌ No ✅ Yes ✅ Yes
App Store Not applicable Optional Required
Updates Automatic Automatic Manual approval
Development Cost Low Low-Medium High (per platform)
Discoverability ✅ SEO/Links ✅ SEO/Links App Store only
Hardware Access Limited Good (improving) Full

Service Workers: The Heart of PWAs

Almost everything that makes a PWA more than a website goes through one file. A service worker runs in the background on its own thread, with no DOM access, and sits between your pages and the network like a small programmable proxy. Offline support, push and background sync all hang off it.

Service Worker Architecture Web App HTML/CSS/JS API Calls Assets requests Service Worker Intercept Requests Cache Management Background Sync cache network Cache API Local Storage Network Remote Server

Service Workers intercept all network requests and can serve cached content or fetch from network

Service Worker Lifecycle

Most "why is my site still showing the old version?" bugs come from the lifecycle. A new service worker installs alongside the old one and, by default, waits until every tab using the old one is closed before it takes over.

1. Install Event

Fires once per new version of the file. This is where you precache static assets (the app shell).

2. Activate Event

Fires when the new SW takes control. Delete old caches here.

3. Fetch Event

Fires for every request the page makes in scope. Your caching strategy lives here.

Basic Service Worker Implementation

sw.js
const CACHE_NAME = 'my-pwa-cache-v1';
const urlsToCache = [
  '/',
  '/index.html',
  '/styles.css',
  '/app.js',
  '/images/logo.png',
  '/offline.html'
];

// Install event - cache static assets
self.addEventListener('install', event => {
  console.log('Service Worker: Installing...');
  
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then(cache => {
        console.log('Service Worker: Caching app shell');
        return cache.addAll(urlsToCache);
      })
      .then(() => self.skipWaiting())
  );
});

// Activate event - clean up old caches
self.addEventListener('activate', event => {
  console.log('Service Worker: Activating...');
  
  event.waitUntil(
    caches.keys().then(cacheNames => {
      return Promise.all(
        cacheNames.map(cache => {
          if (cache !== CACHE_NAME) {
            console.log('Service Worker: Clearing old cache');
            return caches.delete(cache);
          }
        })
      );
    }).then(() => self.clients.claim())
  );
});

// Fetch event - serve from cache, fallback to network
self.addEventListener('fetch', event => {
  event.respondWith(
    caches.match(event.request)
      .then(response => {
        // Return cached version or fetch from network
        if (response) {
          return response;
        }
        return fetch(event.request);
      })
      .catch(() => {
        // If both cache and network fail, show offline page
        if (event.request.mode === 'navigate') {
          return caches.match('/offline.html');
        }
      })
  );
});
Two gotchas with this file
  • cache.addAll() is all or nothing. If a single URL in urlsToCache returns a 404 (a renamed image, a hashed filename that changed on deploy), the whole install fails and the new worker never activates. In the console you'll see something like Uncaught (in promise) TypeError: Failed to execute 'addAll' on 'Cache': Request failed.
  • Scope follows the file's location. A worker served from /js/sw.js can only control pages under /js/. Serve it from the site root, and send it with Cache-Control: no-cache so a CDN or browser cache doesn't keep handing out the old version.

Registering the Service Worker

app.js
// Register service worker
if ('serviceWorker' in navigator) {
  window.addEventListener('load', async () => {
    try {
      const registration = await navigator.serviceWorker.register('/sw.js');
      
      console.log('ServiceWorker registered:', registration.scope);
      
      // Check for updates
      registration.addEventListener('updatefound', () => {
        const newWorker = registration.installing;
        console.log('New service worker available!');
        
        newWorker.addEventListener('statechange', () => {
          if (newWorker.state === 'installed') {
            // New content available, notify user
            showUpdateNotification();
          }
        });
      });
    } catch (error) {
      console.error('ServiceWorker registration failed:', error);
    }
  });
}

Web App Manifest: Making Your App Installable

Without a manifest the browser has no name, icon or start URL to use, so it won't offer to install your site. The manifest is a small JSON file that supplies those, plus the splash screen colours and how the app window should look once it's installed.

manifest.json
{
  "name": "My Progressive Web App",
  "short_name": "MyPWA",
  "description": "A fast, reliable, and engaging PWA",
  "start_url": "/?source=pwa",
  "scope": "/",
  "display": "standalone",
  "orientation": "portrait-primary",
  "background_color": "#ffffff",
  "theme_color": "#5a0fc8",
  "icons": [
    {
      "src": "/icons/icon-72x72.png",
      "sizes": "72x72",
      "type": "image/png",
      "purpose": "maskable any"
    },
    {
      "src": "/icons/icon-96x96.png",
      "sizes": "96x96",
      "type": "image/png",
      "purpose": "maskable any"
    },
    {
      "src": "/icons/icon-128x128.png",
      "sizes": "128x128",
      "type": "image/png",
      "purpose": "maskable any"
    },
    {
      "src": "/icons/icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "maskable any"
    },
    {
      "src": "/icons/icon-384x384.png",
      "sizes": "384x384",
      "type": "image/png",
      "purpose": "maskable any"
    },
    {
      "src": "/icons/icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "maskable any"
    }
  ],
  "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"
    }
  ],
  "shortcuts": [
    {
      "name": "New Task",
      "short_name": "Task",
      "url": "/tasks/new",
      "icons": [{ "src": "/icons/add-task.png", "sizes": "192x192" }]
    }
  ],
  "categories": ["productivity", "utilities"]
}
Manifest Tips
  • Maskable icons: Use the purpose: "maskable" for icons that will be cropped on Android
  • Display modes: Options include fullscreen, standalone, minimal-ui, and browser
  • start_url: Add a query parameter to track PWA launches in analytics
  • Screenshots: Required for the enhanced install dialog on desktop Chrome

Linking the Manifest

index.html
<head>
  <!-- Link to manifest -->
  <link rel="manifest" href="/manifest.json">
  
  <!-- Theme color for browser UI -->
  <meta name="theme-color" content="#5a0fc8">
  
  <!-- iOS-specific meta tags -->
  <meta name="apple-mobile-web-app-capable" content="yes">
  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
  <meta name="apple-mobile-web-app-title" content="MyPWA">
  
  <!-- iOS icons -->
  <link rel="apple-touch-icon" href="/icons/icon-192x192.png">
  
  <!-- iOS splash screens -->
  <link rel="apple-touch-startup-image" href="/splash/splash-640x1136.png">
</head>

Caching Strategies

There's no single right strategy. You trade speed against freshness, and the answer is different for your CSS bundle than for your /api/cart endpoint. Most apps end up with a mix of these three:

Common Caching Strategies Cache First (Cache, falling back to Network) Request Cache if not cached ↓ Network Network First (Network, falling back to Cache) Request Network if offline ↓ Cache Stale-While-Revalidate (Cache + Update in Background) Request Cache Network Serve cache immediately Update cache in background When to Use Each Strategy Cache First • Static assets (CSS, JS, images) • Fonts • App shell • Content that rarely changes Network First • API responses • User-specific content • Real-time data • HTML pages Stale-While-Revalidate • News articles • Social media feeds • Product listings • Frequently updated content

Choose your caching strategy based on content type and freshness requirements

Implementing Caching Strategies

// Cache First - Good for static assets
self.addEventListener('fetch', event => {
  if (event.request.destination === 'image' ||
      event.request.destination === 'style' ||
      event.request.destination === 'script') {
    
    event.respondWith(
      caches.match(event.request)
        .then(cachedResponse => {
          if (cachedResponse) {
            return cachedResponse;
          }
          
          return fetch(event.request).then(response => {
            // Cache the new response
            const responseClone = response.clone();
            caches.open('static-cache').then(cache => {
              cache.put(event.request, responseClone);
            });
            return response;
          });
        })
    );
  }
});
// Network First - Good for API calls and HTML
self.addEventListener('fetch', event => {
  if (event.request.url.includes('/api/')) {
    event.respondWith(
      fetch(event.request)
        .then(response => {
          // Clone and cache the response
          const responseClone = response.clone();
          caches.open('api-cache').then(cache => {
            cache.put(event.request, responseClone);
          });
          return response;
        })
        .catch(() => {
          // Network failed, try cache
          return caches.match(event.request);
        })
    );
  }
});
// Stale-While-Revalidate - Best of both worlds
self.addEventListener('fetch', event => {
  event.respondWith(
    caches.open('dynamic-cache').then(async cache => {
      const cachedResponse = await cache.match(event.request);
      
      // Fetch fresh data in the background
      const fetchPromise = fetch(event.request).then(networkResponse => {
        cache.put(event.request, networkResponse.clone());
        return networkResponse;
      });
      
      // Return cached version immediately, or wait for network
      return cachedResponse || fetchPromise;
    })
  );
});

Handling the Install Prompt

Chromium browsers fire a beforeinstallprompt event when your site meets the install criteria. You can grab it and show the prompt from your own button, at a moment that makes sense, instead of letting the browser pick. Safari and Firefox don't fire this event, so on iOS users still install through Share, then "Add to Home Screen".

📱 Install Our App

Add it to your home screen and it opens in its own window.

install-prompt.js
let deferredPrompt;
const installButton = document.getElementById('installButton');
const installBanner = document.getElementById('installBanner');

// Capture the install prompt event
window.addEventListener('beforeinstallprompt', (e) => {
  // Prevent the default mini-infobar
  e.preventDefault();
  
  // Store the event for later use
  deferredPrompt = e;
  
  // Show your custom install UI
  installBanner.style.display = 'block';
  
  // Track that install prompt was shown
  trackEvent('pwa_install_prompt_shown');
});

// Handle the install button click
installButton.addEventListener('click', async () => {
  if (!deferredPrompt) {
    return;
  }
  
  // Show the native install prompt
  deferredPrompt.prompt();
  
  // Wait for the user's response
  const { outcome } = await deferredPrompt.userChoice;
  
  if (outcome === 'accepted') {
    console.log('User accepted the install prompt');
    trackEvent('pwa_install_accepted');
  } else {
    console.log('User dismissed the install prompt');
    trackEvent('pwa_install_dismissed');
  }
  
  // Clear the deferredPrompt
  deferredPrompt = null;
  installBanner.style.display = 'none';
});

// Detect when the PWA was successfully installed
window.addEventListener('appinstalled', (e) => {
  console.log('PWA was installed!');
  trackEvent('pwa_installed');
  
  // Hide the install UI
  installBanner.style.display = 'none';
});

// Check if app is running in standalone mode (installed)
if (window.matchMedia('(display-mode: standalone)').matches) {
  console.log('App is running in standalone mode');
  // Adjust UI for installed app experience
}

Push Notifications

Push lets your server reach a user when the app isn't open, because the browser wakes your service worker to show the notification. It's also the fastest way to get your site blocked. Ask for permission after the user does something that makes notifications useful (subscribing to an order update, say), never on page load. On iOS, web push only works once the PWA has been added to the home screen (iOS 16.4 and later).

Push Notification Flow 1 Subscribe User grants permission Push Service (FCM/Web Push) Stores subscription 2 Register endpoint Your Server Stores subscription Sends notifications 3 Save to database Service Worker Receives push Shows notification 4 Display to user User interacts → Opens app
push-notifications.js
// Request permission and subscribe to push notifications
async function subscribeToPush() {
  try {
    // Request notification permission
    const permission = await Notification.requestPermission();
    
    if (permission !== 'granted') {
      console.log('Notification permission denied');
      return;
    }
    
    // Get the service worker registration
    const registration = await navigator.serviceWorker.ready;
    
    // Subscribe to push notifications
    const subscription = await registration.pushManager.subscribe({
      userVisibleOnly: true,
      applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY)
    });
    
    // Send subscription to your server
    await fetch('/api/push/subscribe', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(subscription)
    });
    
    console.log('Successfully subscribed to push notifications!');
  } catch (error) {
    console.error('Error subscribing to push:', error);
  }
}

// Helper function to convert VAPID key
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 Events in Service Worker

sw.js - Push Event Handler
// Handle incoming push notifications
self.addEventListener('push', event => {
  let data = { title: 'New Notification', body: 'You have a new message!' };
  
  if (event.data) {
    data = event.data.json();
  }
  
  const options = {
    body: data.body,
    icon: '/icons/icon-192x192.png',
    badge: '/icons/badge-72x72.png',
    vibrate: [100, 50, 100],
    data: {
      url: data.url || '/',
      dateOfArrival: Date.now()
    },
    actions: [
      { action: 'open', title: 'Open', icon: '/icons/open.png' },
      { action: 'dismiss', title: 'Dismiss', icon: '/icons/dismiss.png' }
    ]
  };
  
  event.waitUntil(
    self.registration.showNotification(data.title, options)
  );
});

// Handle notification click
self.addEventListener('notificationclick', event => {
  event.notification.close();
  
  if (event.action === 'dismiss') {
    return;
  }
  
  const urlToOpen = event.notification.data.url;
  
  event.waitUntil(
    clients.matchAll({ type: 'window' }).then(windowClients => {
      // Check if a window is already open
      for (const client of windowClients) {
        if (client.url === urlToOpen && 'focus' in client) {
          return client.focus();
        }
      }
      // Open a new window
      return clients.openWindow(urlToOpen);
    })
  );
});

Background Sync

Background Sync lets you hold a request until the device is back online, so a form submitted in a tunnel still reaches your server later. Browser support is the catch: it's Chromium only. Safari and Firefox don't have the sync API, so keep a fallback that retries from the page on the next load.

background-sync.js
// Register a background sync when offline
async function submitFormWithSync(formData) {
  try {
    // Try to submit immediately
    await fetch('/api/submit', {
      method: 'POST',
      body: JSON.stringify(formData)
    });
    showNotification('Form submitted successfully!');
  } catch (error) {
    // Network failed, queue for background sync
    await storeFormData(formData);
    await registerBackgroundSync('form-sync');
    showNotification('Form saved! Will sync when online.');
  }
}

async function registerBackgroundSync(tag) {
  const registration = await navigator.serviceWorker.ready;
  await registration.sync.register(tag);
}

// Store data in IndexedDB for later sync
async function storeFormData(data) {
  const db = await openDB('sync-queue', 1);
  await db.add('pending-forms', {
    data,
    timestamp: Date.now()
  });
}
sw.js - Sync Event Handler
// Handle background sync in service worker
self.addEventListener('sync', event => {
  if (event.tag === 'form-sync') {
    event.waitUntil(syncPendingForms());
  }
});

async function syncPendingForms() {
  const db = await openDB('sync-queue', 1);
  const pendingForms = await db.getAll('pending-forms');
  
  for (const form of pendingForms) {
    try {
      await fetch('/api/submit', {
        method: 'POST',
        body: JSON.stringify(form.data)
      });
      await db.delete('pending-forms', form.id);
    } catch (error) {
      // Sync will be retried automatically
      throw error;
    }
  }
}

Using Workbox for Easier PWA Development

Writing fetch handlers by hand is fine for learning, but in a real project I'd use Workbox. It's Google's set of service worker libraries, and it covers the parts that are easy to get wrong: precaching with revisioned URLs, cache expiration, and the strategies above as ready-made classes.

workbox-config.js
// workbox-config.js
module.exports = {
  globDirectory: 'dist/',
  globPatterns: [
    '**/*.{html,js,css,png,jpg,svg,woff2}'
  ],
  swDest: 'dist/sw.js',
  runtimeCaching: [
    {
      urlPattern: /^https:\/\/api\.example\.com\//,
      handler: 'NetworkFirst',
      options: {
        cacheName: 'api-cache',
        expiration: {
          maxEntries: 50,
          maxAgeSeconds: 86400
        }
      }
    },
    {
      urlPattern: /\.(?:png|jpg|jpeg|svg|gif)$/,
      handler: 'CacheFirst',
      options: {
        cacheName: 'image-cache',
        expiration: {
          maxEntries: 100,
          maxAgeSeconds: 604800
        }
      }
    }
  ]
};
sw.js with Workbox
import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { 
  CacheFirst, 
  NetworkFirst, 
  StaleWhileRevalidate 
} from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';

// Precache static assets
precacheAndRoute(self.__WB_MANIFEST);

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

// Cache API calls with Network First strategy
registerRoute(
  ({ url }) => url.pathname.startsWith('/api/'),
  new NetworkFirst({
    cacheName: 'api-responses',
    networkTimeoutSeconds: 3,
    plugins: [
      new ExpirationPlugin({
        maxEntries: 50,
        maxAgeSeconds: 24 * 60 * 60 // 24 hours
      })
    ]
  })
);

// Cache pages with Stale While Revalidate
registerRoute(
  ({ request }) => request.mode === 'navigate',
  new StaleWhileRevalidate({
    cacheName: 'pages'
  })
);

PWA Checklist

Before deploying, go through this list:

HTTPS enabled
Valid Web App Manifest
Service Worker registered
Offline functionality
Icons (192px & 512px)
Meta viewport tag
Fast loading (< 3s)
Responsive design
Theme color set
Start URL specified
iOS meta tags (optional)
Screenshots for install UI

Testing with Lighthouse

Older tutorials tell you to tick the "Progressive Web App" box in Lighthouse. That category was removed in Lighthouse 12 (2024), so newer Chrome versions don't have it. These days I check installability in the Application panel and use Lighthouse for performance and best practices.

1

Open Chrome DevTools

Right-click on your page and select "Inspect" or press F12

2

Check the Application Panel

Under "Manifest", Chrome lists any installability errors (missing icon size, bad start_url). Under "Service workers", confirm yours is activated and running.

3

Test Offline

Tick "Offline" in the Service workers section (or the Network tab throttling menu) and reload. You should get your app or offline page, not the dinosaur.

4

Run Lighthouse

Open the "Lighthouse" tab, pick Performance and Best Practices, and click "Analyze page load"

Debugging tips
  • Test on real devices: Emulators miss a lot, especially iOS install and push behaviour
  • Tick "Update on reload" while developing: it's in Application, then Service workers, and saves you from closing every tab to see your changes
  • Use Chrome's Application panel: Debug service workers, manifest, and cache
  • Version your cache: Always change cache names when updating static assets
  • Handle updates gracefully: Notify users when new versions are available
  • Monitor with analytics: Track install rates, offline usage, and engagement

Who Has Shipped PWAs

Several large sites have published case studies on their PWAs, mostly built for users on slow mobile connections. Treat the numbers in those write-ups as their results, not something you'll automatically get:

Twitter Lite

Reported more pages per session and more Tweets sent after the switch

Alibaba

Reported higher mobile conversions across browsers

Pinterest

Rebuilt its mobile site as a PWA and reported higher engagement and ad revenue

Forbes

Reported longer sessions and more engagement on mobile

When I'd Build a PWA (and When I Wouldn't)

A PWA is a good fit when most of your users are on mobile web already, connections are unreliable, and the app is mostly content, forms and dashboards. Order tracking, internal tools for field staff, booking flows and news sites all fit. You get one codebase, instant updates and no store review, and you can still start with just a manifest and a small service worker and add more later.

I wouldn't pick it when:

The mistake I'd watch for most is caching too much too early. A cache-first rule on your HTML, or an app shell that never gets a new cache name, will happily serve last month's build to users long after you've deployed. Start with network-first for pages, cache-first only for hashed static files, and test what an update actually looks like before you ship.

PWA Service Workers Mobile Web App Manifest Offline First Push Notifications
Mayur Dabhi

Mayur Dabhi

Full Stack Developer with 5+ years of experience building modern web applications. Passionate about performance optimization, PWAs, and creating seamless user experiences.