Docs

19.6-Service-Workers-PWA

19.6 Service Workers and Progressive Web Apps

Overview

Service Workers are JavaScript files that run in the background, separate from the main browser thread. They enable powerful features like offline support, push notifications, and background sync - core capabilities for Progressive Web Apps (PWAs).

Learning Objectives

  • Understand the Service Worker lifecycle
  • Implement caching strategies for offline support
  • Register and update Service Workers
  • Build Progressive Web App features
  • Handle background synchronization

Service Worker Basics

Registration

// Register a Service Worker
if ('serviceWorker' in navigator) {
  navigator.serviceWorker
    .register('/sw.js', {
      scope: '/', // Controls which pages this SW manages
    })
    .then((registration) => {
      console.log('SW registered:', registration.scope);
    })
    .catch((error) => {
      console.error('SW registration failed:', error);
    });
}

// Check registration status
navigator.serviceWorker.ready.then((registration) => {
  console.log('SW is active and controlling the page');
});

Lifecycle Events

// sw.js - Service Worker file

// Install event - cache static assets
self.addEventListener('install', (event) => {
  console.log('SW: Installing...');

  event.waitUntil(
    caches.open('v1').then((cache) => {
      return cache.addAll([
        '/',
        '/index.html',
        '/styles.css',
        '/app.js',
        '/offline.html',
      ]);
    })
  );
});

// Activate event - cleanup old caches
self.addEventListener('activate', (event) => {
  console.log('SW: Activating...');

  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames
          .filter((name) => name !== 'v1')
          .map((name) => caches.delete(name))
      );
    })
  );
});

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

Caching Strategies

Cache First (Offline First)

// Good for static assets that rarely change
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((cachedResponse) => {
      if (cachedResponse) {
        return cachedResponse;
      }
      return fetch(event.request);
    })
  );
});

Network First

// Good for frequently updated content
self.addEventListener('fetch', (event) => {
  event.respondWith(
    fetch(event.request)
      .then((response) => {
        // Clone and cache the response
        const responseClone = response.clone();
        caches.open('dynamic-v1').then((cache) => {
          cache.put(event.request, responseClone);
        });
        return response;
      })
      .catch(() => {
        return caches.match(event.request);
      })
  );
});

Stale While Revalidate

// Best of both worlds - fast + fresh
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.open('dynamic-v1').then((cache) => {
      return cache.match(event.request).then((cachedResponse) => {
        const fetchPromise = fetch(event.request).then((networkResponse) => {
          cache.put(event.request, networkResponse.clone());
          return networkResponse;
        });

        return cachedResponse || fetchPromise;
      });
    })
  );
});

Cache with Network Fallback

// Sophisticated strategy with timeout
const CACHE_NAME = 'app-v1';
const NETWORK_TIMEOUT = 3000;

self.addEventListener('fetch', (event) => {
  const { request } = event;

  // Skip non-GET requests
  if (request.method !== 'GET') {
    return;
  }

  event.respondWith(
    Promise.race([
      // Try network with timeout
      fetchWithTimeout(request, NETWORK_TIMEOUT),
      // Fallback to cache after timeout
      new Promise((resolve, reject) => {
        setTimeout(() => {
          caches.match(request).then((response) => {
            if (response) resolve(response);
            else reject('No cache available');
          });
        }, NETWORK_TIMEOUT);
      }),
    ]).catch(() => caches.match('/offline.html'))
  );
});

function fetchWithTimeout(request, timeout) {
  return new Promise((resolve, reject) => {
    const timer = setTimeout(() => reject('Timeout'), timeout);

    fetch(request)
      .then((response) => {
        clearTimeout(timer);
        resolve(response);
      })
      .catch(reject);
  });
}

Progressive Web App Features

Web App Manifest

// manifest.json
{
  "name": "My Progressive Web App",
  "short_name": "MyPWA",
  "description": "A sample PWA",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#3498db",
  "icons": [
    {
      "src": "/icons/icon-192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-512.png",
      "sizes": "512x512",
      "type": "image/png"
    }
  ]
}
<!-- Link in HTML -->
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#3498db" />

Install Prompt

let deferredPrompt;

window.addEventListener('beforeinstallprompt', (event) => {
  event.preventDefault();
  deferredPrompt = event;

  // Show custom install button
  showInstallButton();
});

function showInstallButton() {
  const button = document.getElementById('install-btn');
  button.style.display = 'block';

  button.addEventListener('click', async () => {
    deferredPrompt.prompt();
    const { outcome } = await deferredPrompt.userChoice;
    console.log('User choice:', outcome);
    deferredPrompt = null;
    button.style.display = 'none';
  });
}

// Detect successful installation
window.addEventListener('appinstalled', () => {
  console.log('App was installed');
});

Background Sync

// Main thread - queue sync
navigator.serviceWorker.ready.then((registration) => {
  return registration.sync.register('sync-data');
});

// sw.js - handle sync
self.addEventListener('sync', (event) => {
  if (event.tag === 'sync-data') {
    event.waitUntil(syncData());
  }
});

async function syncData() {
  const db = await openDB();
  const pendingData = await db.getAll('pending-sync');

  for (const item of pendingData) {
    try {
      await fetch('/api/sync', {
        method: 'POST',
        body: JSON.stringify(item.data),
      });
      await db.delete('pending-sync', item.id);
    } catch (error) {
      console.error('Sync failed:', error);
      throw error; // Retry later
    }
  }
}

Push Notifications

// Request permission
async function requestNotificationPermission() {
  const permission = await Notification.requestPermission();

  if (permission === 'granted') {
    const registration = await navigator.serviceWorker.ready;
    const subscription = await registration.pushManager.subscribe({
      userVisibleOnly: true,
      applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
    });

    // Send subscription to server
    await fetch('/api/subscribe', {
      method: 'POST',
      body: JSON.stringify(subscription),
    });
  }
}

// sw.js - handle push
self.addEventListener('push', (event) => {
  const data = event.data?.json() || { title: 'Notification' };

  event.waitUntil(
    self.registration.showNotification(data.title, {
      body: data.body,
      icon: '/icons/icon-192.png',
      badge: '/icons/badge.png',
      data: data.url,
    })
  );
});

// Handle notification click
self.addEventListener('notificationclick', (event) => {
  event.notification.close();

  event.waitUntil(clients.openWindow(event.notification.data || '/'));
});

Update Handling

// Detect updates in main thread
navigator.serviceWorker.register('/sw.js').then((registration) => {
  registration.addEventListener('updatefound', () => {
    const newWorker = registration.installing;

    newWorker.addEventListener('statechange', () => {
      if (
        newWorker.state === 'installed' &&
        navigator.serviceWorker.controller
      ) {
        // New version available
        showUpdateNotification();
      }
    });
  });
});

// Skip waiting and activate immediately
function skipWaiting() {
  navigator.serviceWorker.controller?.postMessage({ type: 'SKIP_WAITING' });
}

// sw.js - handle skip waiting
self.addEventListener('message', (event) => {
  if (event.data?.type === 'SKIP_WAITING') {
    self.skipWaiting();
  }
});

Offline Detection

// Check online status
function isOnline() {
  return navigator.onLine;
}

// Listen for changes
window.addEventListener('online', () => {
  console.log('Back online');
  syncPendingData();
});

window.addEventListener('offline', () => {
  console.log('Gone offline');
  showOfflineIndicator();
});

Best Practices

  1. Versioned Caches - Name caches with versions for easy updates
  2. Selective Caching - Cache what makes sense for your app
  3. Graceful Degradation - Always provide fallbacks
  4. Update Strategy - Decide how to handle SW updates
  5. Minimal SW Code - Keep Service Worker lean
  6. Test Offline - Use DevTools to simulate offline mode

Summary

FeaturePurpose
Service WorkerBackground script for offline/caching
Cache APIStore and retrieve cached responses
ManifestPWA metadata and appearance
Push APIReceive push notifications
Background SyncDefer actions until online

Resources

.6 Service Workers PWA - JavaScript Tutorial | DeepML