PWA PROGRESSIVE β€’ INSTALLABLE β€’ OFFLINE
Frontend

Building Progressive Web Apps (PWA)

Mayur Dabhi
Mayur Dabhi
March 28, 2026
24 min read

Progressive Web Apps (PWAs) represent a paradigm shift in web development, combining the best of web and native applications. They're websites that can be installed on devices, work offline, send push notifications, and provide an app-like experienceβ€”all while being built with standard web technologies like HTML, CSS, and JavaScript.

In this comprehensive guide, we'll explore everything you need to know to build production-ready PWAs, from understanding service workers to implementing advanced caching strategies and making your app fully installable across all platforms.

What You'll Learn
  • Understanding the core principles and architecture of PWAs
  • Implementing Service Workers for offline functionality
  • Creating a Web App Manifest for installability
  • Mastering caching strategies (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 PWAs with Lighthouse

What Makes an App "Progressive"?

The term "Progressive" refers to the philosophy of progressively enhancing your web app based on the capabilities of the user's browser and device. A PWA works for everyone but provides enhanced experiences for users with modern browsers.

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 core pillars that define a Progressive Web App

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

Service Workers are JavaScript files that run in the background, separate from your web page. They act as a proxy between your web app and the network, enabling features like offline functionality, push notifications, and background sync.

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

Understanding the service worker lifecycle is crucial for building reliable PWAs. A service worker goes through several states from registration to activation.

1. Install Event

Triggered when the service worker is first registered. Use this to cache static assets (app shell).

2. Activate Event

Triggered after installation when the SW takes control. Clean up old caches here.

3. Fetch Event

Triggered on every network request. Implement your caching strategy 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');
        }
      })
  );
});

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

The Web App Manifest is a JSON file that tells the browser about your PWA and how it should behave when installed. It enables the "Add to Home Screen" functionality and controls the splash screen, app name, icons, and more.

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

Choosing the right caching strategy is crucial for balancing between performance and data freshness. Here are the most common strategies used in PWAs:

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

One of the most engaging features of PWAs is the ability to prompt users to install the app. Here's how to implement a custom install experience:

πŸ“± Install Our App

Get the full experience with our progressive web app!

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 notifications allow you to re-engage users even when they're not actively using your app. They're one of the most powerful features of PWAs for driving user engagement.

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 allows you to defer actions until the user has stable connectivity. This is perfect for ensuring that user actions like form submissions or data uploads are never lost, even if the connection drops.

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

Workbox is a set of libraries from Google that makes building PWAs much easier. It handles all the complexity of service worker caching strategies, precaching, and more with minimal configuration.

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 your PWA to production, make sure you've covered all the essential requirements:

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

Lighthouse is an open-source tool from Google that audits your PWA for performance, accessibility, and best practices. You can run it directly from Chrome DevTools.

1

Open Chrome DevTools

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

2

Navigate to Lighthouse Tab

Click on the "Lighthouse" tab in DevTools

3

Select PWA Category

Check "Progressive Web App" along with other categories

4

Run the Audit

Click "Analyze page load" and wait for the report

Pro Tips for PWA Development
  • Test on real devices: Emulators don't catch all PWA issues
  • 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

Real-World PWA Success Stories

Major companies have seen significant improvements after implementing PWAs:

Twitter Lite

65% increase in pages per session, 75% increase in Tweets sent

Alibaba

76% higher conversions across browsers

Pinterest

60% increase in core engagements, 44% user-generated ad revenue increase

Forbes

100% increase in engagement, 43% more sessions per user

Conclusion

Progressive Web Apps represent the future of web development, bridging the gap between web and native applications. By implementing service workers, web app manifests, and modern caching strategies, you can create fast, reliable, and engaging experiences that work for all users.

The key takeaways from this guide:

Start building your PWA today and deliver app-like experiences to your users without the friction of app store downloads!

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.