Index · How it works

1 min read
Mid-level3 min read
Rapid overview

How it works

Table of Contents


Service Worker Fundamentals

What is a Service Worker?

A Service Worker is a JavaScript file that runs in the background, separate from the web page, enabling features that don't need a web page or user interaction.

Basic Service Worker Registration

// main.js - Register service worker
if ('serviceWorker' in navigator) {
  window.addEventListener('load', async () => {
    try {
      const registration = await navigator.serviceWorker.register('/sw.js', {
        scope: '/'
      });
      console.log('Service Worker registered:', registration.scope);

      // Listen for updates
      registration.addEventListener('updatefound', () => {
        const newWorker = registration.installing;
        console.log('New service worker installing...');

        newWorker.addEventListener('statechange', () => {
          if (newWorker.state === 'activated') {
            console.log('New service worker activated!');
          }
        });
      });
    } catch (error) {
      console.error('Service Worker registration failed:', error);
    }
  });
}

Service Worker Lifecycle

// sw.js - Service Worker file
const CACHE_VERSION = 'v1';
const CACHE_NAME = `app-cache-${CACHE_VERSION}`;

// 1. Install Event - Cache static assets
self.addEventListener('install', (event) => {
  console.log('Service Worker installing...');

  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => {
      return cache.addAll([
        '/',
        '/index.html',
        '/styles.css',
        '/app.js',
        '/manifest.json',
        '/images/icon-192.png',
        '/images/icon-512.png'
      ]);
    }).then(() => {
      // Skip waiting to activate immediately
      return self.skipWaiting();
    })
  );
});

// 2. 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
          .filter((name) => name !== CACHE_NAME)
          .map((name) => caches.delete(name))
      );
    }).then(() => {
      // Take control of all pages immediately
      return self.clients.claim();
    })
  );
});

// 3. Fetch Event - Intercept network requests
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((cachedResponse) => {
      if (cachedResponse) {
        return cachedResponse;
      }
      return fetch(event.request);
    })
  );
});

PWA Requirements

Web App Manifest

// manifest.json
{
  "name": "My Progressive Web App",
  "short_name": "My PWA",
  "description": "A progressive web application",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#3f51b5",
  "orientation": "portrait-primary",
  "icons": [
    {
      "src": "/images/icon-72.png",
      "sizes": "72x72",
      "type": "image/png"
    },
    {
      "src": "/images/icon-96.png",
      "sizes": "96x96",
      "type": "image/png"
    },
    {
      "src": "/images/icon-128.png",
      "sizes": "128x128",
      "type": "image/png"
    },
    {
      "src": "/images/icon-144.png",
      "sizes": "144x144",
      "type": "image/png"
    },
    {
      "src": "/images/icon-192.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "any maskable"
    },
    {
      "src": "/images/icon-512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "any maskable"
    }
  ],
  "screenshots": [
    {
      "src": "/images/screenshot1.png",
      "sizes": "540x720",
      "type": "image/png"
    }
  ],
  "categories": ["productivity", "utilities"],
  "share_target": {
    "action": "/share",
    "method": "POST",
    "enctype": "multipart/form-data",
    "params": {
      "title": "title",
      "text": "text",
      "url": "url"
    }
  }
}

HTML Requirements

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">

  <!-- PWA Manifest -->
  <link rel="manifest" href="/manifest.json">

  <!-- Theme Color -->
  <meta name="theme-color" content="#3f51b5">

  <!-- Apple Touch Icons -->
  <link rel="apple-touch-icon" href="/images/icon-192.png">
  <meta name="apple-mobile-web-app-capable" content="yes">
  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">

  <!-- MS Tiles -->
  <meta name="msapplication-TileImage" content="/images/icon-144.png">
  <meta name="msapplication-TileColor" content="#3f51b5">

  <title>My PWA</title>
</head>
<body>
  <div id="app"></div>
  <script src="/app.js"></script>
</body>
</html>

Caching Strategies

1. Cache First (Offline First)

Best for static assets that rarely change.

// Cache first, fall back to network
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((cachedResponse) => {
      return cachedResponse || fetch(event.request).then((response) => {
        // Cache the new response for future use
        return caches.open(CACHE_NAME).then((cache) => {
          cache.put(event.request, response.clone());
          return response;
        });
      });
    }).catch(() => {
      // Return offline page if both cache and network fail
      return caches.match('/offline.html');
    })
  );
});

2. Network First (Network Falling Back to Cache)

Best for frequently updated content.

// Network first, fall back to cache
self.addEventListener('fetch', (event) => {
  event.respondWith(
    fetch(event.request)
      .then((response) => {
        // Update cache with fresh response
        const responseClone = response.clone();
        caches.open(CACHE_NAME).then((cache) => {
          cache.put(event.request, responseClone);
        });
        return response;
      })
      .catch(() => {
        // Network failed, try cache
        return caches.match(event.request);
      })
  );
});

3. Stale While Revalidate

Best for content that can be slightly outdated.

// Return cached version immediately, update cache in background
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.open(CACHE_NAME).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 cached response immediately, or wait for network
        return cachedResponse || fetchPromise;
      });
    })
  );
});

4. Network Only

For requests that must always be fresh (POST, real-time data).

self.addEventListener('fetch', (event) => {
  if (event.request.method === 'POST') {
    event.respondWith(fetch(event.request));
  }
});

5. Cache Only

For pre-cached static assets.

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

Advanced: Strategy by Request Type

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

  // API requests - Network first
  if (url.pathname.startsWith('/api/')) {
    event.respondWith(networkFirst(request));
  }
  // Images - Cache first
  else if (request.destination === 'image') {
    event.respondWith(cacheFirst(request));
  }
  // HTML pages - Stale while revalidate
  else if (request.destination === 'document') {
    event.respondWith(staleWhileRevalidate(request));
  }
  // Everything else - Network first
  else {
    event.respondWith(networkFirst(request));
  }
});

async function cacheFirst(request) {
  const cache = await caches.open(CACHE_NAME);
  const cached = await cache.match(request);
  if (cached) return cached;

  const response = await fetch(request);
  cache.put(request, response.clone());
  return response;
}

async function networkFirst(request) {
  try {
    const response = await fetch(request);
    const cache = await caches.open(CACHE_NAME);
    cache.put(request, response.clone());
    return response;
  } catch (error) {
    const cached = await caches.match(request);
    if (cached) return cached;
    throw error;
  }
}

async function staleWhileRevalidate(request) {
  const cache = await caches.open(CACHE_NAME);
  const cached = await cache.match(request);

  const fetchPromise = fetch(request).then((response) => {
    cache.put(request, response.clone());
    return response;
  });

  return cached || fetchPromise;
}

Background Sync

Register Background Sync

// Register sync when online status changes
async function registerBackgroundSync() {
  if ('serviceWorker' in navigator && 'sync' in self.registration) {
    try {
      await navigator.serviceWorker.ready;
      await self.registration.sync.register('sync-messages');
      console.log('Background sync registered');
    } catch (error) {
      console.error('Background sync failed:', error);
    }
  }
}

// Queue data for sync when offline
async function sendMessage(message) {
  if (navigator.onLine) {
    // Send immediately if online
    await fetch('/api/messages', {
      method: 'POST',
      body: JSON.stringify(message)
    });
  } else {
    // Queue for background sync
    const db = await openDB();
    await db.add('pending-messages', message);
    await registerBackgroundSync();
  }
}

Handle Sync Event in Service Worker

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

async function syncMessages() {
  const db = await openDB();
  const messages = await db.getAll('pending-messages');

  for (const message of messages) {
    try {
      await fetch('/api/messages', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(message)
      });
      await db.delete('pending-messages', message.id);
    } catch (error) {
      console.error('Failed to sync message:', error);
      // Will retry on next sync
    }
  }
}

Push Notifications

Request Permission

// Request notification permission
async function requestNotificationPermission() {
  if (!('Notification' in window)) {
    console.log('Notifications not supported');
    return false;
  }

  const permission = await Notification.requestPermission();
  return permission === 'granted';
}

// Subscribe to push notifications
async function subscribeToPush() {
  const registration = await navigator.serviceWorker.ready;

  const subscription = await registration.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: urlBase64ToUint8Array(PUBLIC_VAPID_KEY)
  });

  // Send subscription to server
  await fetch('/api/push/subscribe', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(subscription)
  });

  return subscription;
}

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)));
}

Handle Push Events

// sw.js - Handle push notifications
self.addEventListener('push', (event) => {
  const data = event.data?.json() ?? {};
  const title = data.title || 'New Notification';
  const options = {
    body: data.body || 'You have a new message',
    icon: '/images/icon-192.png',
    badge: '/images/badge-72.png',
    tag: data.tag || 'default',
    data: data.url,
    actions: [
      { action: 'open', title: 'Open' },
      { action: 'close', title: 'Close' }
    ],
    vibrate: [200, 100, 200],
    requireInteraction: false
  };

  event.waitUntil(
    self.registration.showNotification(title, options)
  );
});

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

  if (event.action === 'open' || !event.action) {
    const urlToOpen = event.notification.data || '/';

    event.waitUntil(
      clients.matchAll({ type: 'window', includeUncontrolled: true })
        .then((clientList) => {
          // Focus existing window if available
          for (const client of clientList) {
            if (client.url === urlToOpen && 'focus' in client) {
              return client.focus();
            }
          }
          // Open new window
          if (clients.openWindow) {
            return clients.openWindow(urlToOpen);
          }
        })
    );
  }
});

Installation & Updates

Prompt User to Install PWA

let deferredPrompt;

window.addEventListener('beforeinstallprompt', (event) => {
  // Prevent default prompt
  event.preventDefault();
  deferredPrompt = event;

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

async function showInstallPrompt() {
  if (!deferredPrompt) return;

  deferredPrompt.prompt();
  const { outcome } = await deferredPrompt.userChoice;

  console.log(`User ${outcome === 'accepted' ? 'accepted' : 'dismissed'} install`);
  deferredPrompt = null;
}

// Detect if app was installed
window.addEventListener('appinstalled', () => {
  console.log('PWA was installed');
  hideInstallButton();
});

// Detect if running as PWA
function isPWA() {
  return window.matchMedia('(display-mode: standalone)').matches ||
         window.navigator.standalone === true;
}

Handle Service Worker Updates

async function checkForUpdates() {
  const registration = await navigator.serviceWorker.ready;
  await registration.update();
}

navigator.serviceWorker.addEventListener('controllerchange', () => {
  // New service worker has taken control
  console.log('New service worker activated');
  window.location.reload();
});

// Show update notification
function showUpdateNotification(registration) {
  const notification = document.createElement('div');
  notification.innerHTML = `
    <div class="update-banner">
      <p>A new version is available!</p>
      <button id="reload-btn">Reload</button>
    </div>
  `;
  document.body.appendChild(notification);

  document.getElementById('reload-btn').addEventListener('click', () => {
    const waiting = registration.waiting;
    if (waiting) {
      waiting.postMessage({ type: 'SKIP_WAITING' });
    }
  });
}

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

Best Practices

1. Versioning and Cache Management

const VERSION = '1.2.3';
const CACHE_NAME = `app-v${VERSION}`;
const CACHE_ASSETS = [
  '/',
  '/offline.html',
  '/styles.css',
  '/app.js'
];

// Use versioned cache names
async function updateCache() {
  const cache = await caches.open(CACHE_NAME);
  await cache.addAll(CACHE_ASSETS);
}

// Clean up old versions
async function cleanupCaches() {
  const keys = await caches.keys();
  await Promise.all(
    keys
      .filter(key => key !== CACHE_NAME)
      .map(key => caches.delete(key))
  );
}

2. Handle Offline Gracefully

// Detect online/offline status
window.addEventListener('online', () => {
  console.log('Back online');
  document.body.classList.remove('offline');
  syncPendingRequests();
});

window.addEventListener('offline', () => {
  console.log('Gone offline');
  document.body.classList.add('offline');
  showOfflineBanner();
});

// Initial check
if (!navigator.onLine) {
  document.body.classList.add('offline');
}

3. Scope and HTTPS Requirements

  • Service Workers only work over HTTPS (except localhost)
  • Set appropriate scope for service worker
  • Use secure headers
// Register with specific scope
navigator.serviceWorker.register('/sw.js', {
  scope: '/app/'  // Only control /app/* URLs
});

4. Testing Service Workers

// Unregister all service workers (for testing)
async function unregisterAllServiceWorkers() {
  const registrations = await navigator.serviceWorker.getRegistrations();
  await Promise.all(registrations.map(r => r.unregister()));
}

// Clear all caches (for testing)
async function clearAllCaches() {
  const keys = await caches.keys();
  await Promise.all(keys.map(key => caches.delete(key)));
}