Skip to content
Progressive Web Apps — Complete Guide to Modern Web Apps

Progressive Web Apps — Complete Guide to Modern Web Apps

DodaTech Updated Jun 20, 2026 9 min read

A Progressive Web App (PWA) is a website that behaves like a native app — it can work offline, send push notifications, access device hardware, and be installed on the user’s home screen — all built with standard web technologies.

What You’ll Learn

  • Service workers — the core of PWA functionality
  • Web app manifest for installable apps
  • Offline support and caching strategies
  • Push notifications for user engagement
  • Install prompts and the beforeinstallprompt event

Why It Matters

PWAs combine the reach of the web with the capabilities of native apps. They load instantly (even on slow networks), can be installed without an app store, use less device storage than native apps, and receive push notifications. Companies like Twitter, Pinterest, Starbucks, and Spotify have seen 50-300% increases in user engagement after switching to PWA architectures.

Real-world use: Doda Browser itself is a PWA — it’s installable, works offline with cached pages, and updates transparently. Durga Antivirus Pro offers a PWA dashboard that security analysts can install on their phones for real-time threat monitoring without a native app.

    flowchart LR
  A[Service Worker] --> B[Cache API]
  A --> C[Push API]
  A --> D[Sync Manager]
  E[Web Manifest] --> F[Install Prompt]
  B --> G[Offline Support]
  C --> H[Notifications]
  style A fill:#4af,color:#fff
  style E fill:#f90,color:#fff
  

Service Workers

A service worker is a JavaScript file that runs in the background, separate from the web page. It intercepts network requests, manages caches, and enables offline functionality.

Registering a Service Worker

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

Service Worker Lifecycle

  1. Download — Browser downloads the SW file
  2. Install — Fires on first install or update. Pre-cache critical assets here
  3. Activate — Old caches are cleaned up here
  4. Idle — SW sits idle until events fire
  5. Terminate — Browser may terminate idle SW to save memory

Service Worker Script

// sw.js — placed in the root directory
const CACHE_NAME = 'dodatech-v1';
const STATIC_ASSETS = [
  '/',
  '/index.html',
  '/styles/main.css',
  '/scripts/app.js',
  '/offline.html',
  '/images/logo.webp'
];

// Install event — pre-cache critical assets
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => {
      console.log('Caching static assets');
      return cache.addAll(STATIC_ASSETS);
    })
  );
  // Force waiting SW to become active
  self.skipWaiting();
});

// Activate event — clean old caches
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames
          .filter((name) => name !== CACHE_NAME)
          .map((name) => caches.delete(name))
      );
    })
  );
  // Take control of all clients immediately
  clients.claim();
});

// Fetch event — respond with cache-first strategy
self.addEventListener('fetch', (event) => {
  // Skip non-GET requests
  if (event.request.method !== 'GET') return;
  
  // Skip browser extension requests
  if (event.request.url.startsWith('chrome-extension://')) return;
  
  event.respondWith(
    caches.match(event.request).then((cached) => {
      // Return cached response if available
      if (cached) return cached;
      
      // Otherwise fetch from network
      return fetch(event.request).then((response) => {
        // Don't cache non-successful responses
        if (!response || response.status !== 200 || response.type !== 'basic') {
          return response;
        }
        
        // Clone and cache the response
        const cloned = response.clone();
        caches.open(CACHE_NAME).then((cache) => {
          cache.put(event.request, cloned);
        });
        
        return response;
      }).catch(() => {
        // Network failed — return offline page for navigation requests
        if (event.request.mode === 'navigate') {
          return caches.match('/offline.html');
        }
      });
    })
  );
});

Web App Manifest

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

{
  "name": "Durga Antivirus Pro",
  "short_name": "Durga AV",
  "description": "Real-time antivirus threat monitoring dashboard",
  "start_url": "/dashboard",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#6366f1",
  "orientation": "portrait-primary",
  "icons": [
    {
      "src": "/icons/icon-192.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "any maskable"
    },
    {
      "src": "/icons/icon-512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "any maskable"
    }
  ],
  "screenshots": [
    {
      "src": "/screenshots/dashboard.webp",
      "sizes": "1280x720",
      "type": "image/webp",
      "form_factor": "wide"
    }
  ],
  "categories": ["security", "productivity"],
  "lang": "en",
  "dir": "ltr"
}
<!-- Link the manifest in your HTML -->
<link rel="manifest" href="/manifest.json">

<!-- Meta tags for iOS support -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="Durga AV">
<link rel="apple-touch-icon" href="/icons/icon-192.png">

Offline Support

Service workers enable several strategies for handling offline access.

Caching Strategies

// Strategy 1: Cache First (for static assets)
function cacheFirst(request) {
  return caches.match(request).then((cached) => {
    return cached || fetch(request).then((response) => {
      return caches.open(CACHE_NAME).then((cache) => {
        cache.put(request, response.clone());
        return response;
      });
    });
  });
}

// Strategy 2: Network First (for dynamic content)
function networkFirst(request) {
  return fetch(request).then((response) => {
    return caches.open(CACHE_NAME).then((cache) => {
      cache.put(request, response.clone());
      return response;
    });
  }).catch(() => {
    return caches.match(request).then((cached) => {
      return cached || caches.match('/offline.html');
    });
  });
}

// Strategy 3: Stale While Revalidate (for frequently updated)
function staleWhileRevalidate(request) {
  const cachePromise = caches.open(CACHE_NAME);
  const fetchPromise = fetch(request).then((response) => {
    cachePromise.then((cache) => {
      cache.put(request, response.clone());
    });
    return response;
  }).catch(() => {});
  
  return cachePromise.then((cache) => {
    return cache.match(request).then((cached) => {
      return cached || fetchPromise;
    });
  });
}

Offline Page

<!-- offline.html — shown when user navigates without network -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>You're Offline</title>
  <style>
    body {
      display: flex;
      flex-direction: column;
      align-items: center;
      justify-content: center;
      min-height: 100vh;
      font-family: system-ui, sans-serif;
      text-align: center;
      padding: 20px;
      color: #333;
    }
    h1 { font-size: 2rem; }
    .icon { font-size: 4rem; }
  </style>
</head>
<body>
  <div class="icon">📡</div>
  <h1>You're Offline</h1>
  <p>Please check your internet connection and try again.</p>
  <button onclick="location.reload()">Try Again</button>
</body>
</html>

Push Notifications

Push notifications re-engage users with timely updates, even when the browser is closed.

Requesting Permission

// Request notification permission
async function requestNotificationPermission() {
  if (!('Notification' in window)) {
    console.log('Notifications not supported');
    return false;
  }
  
  const permission = await Notification.requestPermission();
  if (permission === 'granted') {
    console.log('Notification permission granted');
    return true;
  }
  
  console.log('Notification permission denied:', permission);
  return false;
}

// Subscribe to push
async function subscribeToPush() {
  if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
    console.log('Push not supported');
    return;
  }
  
  try {
    const registration = await navigator.serviceWorker.ready;
    
    // Convert VAPID public key to Uint8Array
    const publicKey = 'YOUR_VAPID_PUBLIC_KEY';
    const convertedKey = urlBase64ToUint8Array(publicKey);
    
    const subscription = await registration.pushManager.subscribe({
      userVisibleOnly: true,
      applicationServerKey: convertedKey
    });
    
    // Send subscription to your server
    await fetch('/api/push/subscribe', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(subscription)
    });
    
    console.log('Push subscription successful');
  } catch (error) {
    console.error('Push subscription failed:', error);
  }
}

// Helper: 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)));
}

Service Worker Push Handler

// In sw.js — handle push events
self.addEventListener('push', (event) => {
  if (!event.data) return;
  
  try {
    const data = event.data.json();
    
    const options = {
      title: data.title || 'Durga Antivirus Pro',
      body: data.body || 'New update available',
      icon: '/icons/icon-192.png',
      badge: '/icons/badge-72.png',
      tag: data.tag || 'default',
      data: { url: data.url || '/' },
      vibrate: [200, 100, 200],
      actions: [
        { action: 'view', title: 'View Details' },
        { action: 'dismiss', title: 'Dismiss' }
      ],
      renotify: true,
      requireInteraction: true
    };
    
    event.waitUntil(
      self.registration.showNotification(data.title, options)
    );
  } catch (error) {
    console.error('Push event error:', error);
  }
});

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

Install Prompt

The beforeinstallprompt event lets you prompt users to install your PWA.

let deferredPrompt;

window.addEventListener('beforeinstallprompt', (event) => {
  // Prevent the default mini-infobar
  event.preventDefault();
  
  // Save the event for later use
  deferredPrompt = event;
  
  // Show your custom install button
  const installButton = document.getElementById('install-btn');
  installButton.style.display = 'block';
  
  installButton.addEventListener('click', async () => {
    // Hide the button
    installButton.style.display = 'none';
    
    // Show the install prompt
    deferredPrompt.prompt();
    
    // Wait for user's choice
    const result = await deferredPrompt.userChoice;
    console.log('User chose:', result.outcome);
    
    deferredPrompt = null;
  });
});

// Track installation
window.addEventListener('appinstalled', (event) => {
  console.log('PWA was installed');
  // Send analytics event
  gtag('event', 'install', { 'app_id': 'dodatech-pwa' });
});

Common Errors

  1. Service worker scope too narrow — SW files must be in the root (or the scope specified in the scope option). A SW in /js/sw.js can’t intercept /index.html.
  2. Not handling service worker updates — When a user has an older version cached, they won’t see new content until the SW updates. Use skipWaiting() and clients.claim() or notify users of updates.
  3. Missing HTTPS — Service workers only work on HTTPS (or localhost for development). A non-HTTPS site can’t use PWA features.
  4. Cache-first for dynamic content — Don’t use cache-first for API calls or user-specific data. Use network-first or stale-while-revalidate.
  5. Forgetting the manifest — Without a valid manifest with proper icons and start_url, Chrome won’t fire the install prompt.

Practice Questions

  1. What is a service worker and what can it do? A service worker is a JavaScript file that runs in the background, intercepting network requests, managing caches, and enabling offline support, push notifications, and background sync.

  2. What caching strategy would you use for an API endpoint? Network-first — try the network first; if it fails (offline), serve the cached version. This ensures fresh data when online and graceful fallback when offline.

  3. Why is HTTPS required for service workers? HTTPS prevents man-in-the-middle attacks from injecting malicious service workers. The browser needs to trust that the SW file hasn’t been modified.

  4. How do you trigger the install prompt for a PWA? Listen for the beforeinstallprompt event, prevent default, save the event, and call prompt() on it when the user clicks a custom install button.

  5. What is the difference between skipWaiting() and clients.claim()? skipWaiting() forces a waiting service worker to become active immediately. clients.claim() makes the active SW take control of all open pages (without requiring a page reload).

Challenge

Convert a simple three-page website into a PWA. Add a service worker with cache-first strategy for static assets and network-first for API calls. Create a manifest, add an install button, implement push notifications, and verify everything works offline.

Real-World Task

Build a PWA version of the Durga Antivirus Pro threat monitoring dashboard. It must work fully offline (showing cached data), send push notifications when critical threats are detected, and be installable on both Android and iOS. Include a background sync feature that queues scan results when offline and submits them when connectivity returns.


Related: HTTPS & Security | Previous: JavaScript Fundamentals | Previous: HTML Fundamentals

Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro