Service Worker Reference

Complete guide with code examples — registration, lifecycle, caching strategies, push notifications, background sync, and Cache API.

Registration & Lifecycle

Service workers must be registered from a page. They run in a separate thread and have a defined lifecycle: installactivatedfetch.

Basic Registration

main.js
if ('serviceWorker' in navigator) {
  window.addEventListener('load', async () => {
    try {
      const registration = await navigator.serviceWorker.register('/sw.js', {
        scope: '/'
      });
      console.log('SW registered:', registration.scope);
    } catch (error) {
      console.error('SW registration failed:', error);
    }
  });
}

Check Registration Status

main.js
const registration = await navigator.serviceWorker.getRegistration();
if (registration) {
  console.log('Active SW:', registration.active?.state);
  console.log('Scope:', registration.scope);
} else {
  console.log('No service worker registered');
}

Unregister

main.js
const registration = await navigator.serviceWorker.getRegistration();
if (registration) {
  const success = await registration.unregister();
  console.log('Unregistered:', success);
}

Listen for Updates

main.js
const registration = await navigator.serviceWorker.register('/sw.js');

registration.addEventListener('updatefound', () => {
  const newWorker = registration.installing;
  console.log('New SW found, state:', newWorker.state);

  newWorker.addEventListener('statechange', () => {
    if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
      // New content available, prompt user to refresh
      console.log('New version available; refresh to update.');
    }
  });
});

Lifecycle States

sw.js
// Lifecycle states: "installing" → "installed" → "activating" → "activated" → "redundant"
// Use skipWaiting() and clients.claim() to activate immediately.

self.addEventListener('install', (event) => {
  console.log('Installing...');
  // Force activation without waiting for existing tabs to close
  self.skipWaiting();
});

self.addEventListener('activate', (event) => {
  console.log('Activating...');
  // Take control of all pages immediately
  event.waitUntil(self.clients.claim());
});

Install & Activate Events

The install event fires once when the SW is first registered. The activate event fires when the SW takes control.

Pre-cache Assets on Install

sw.js
const CACHE_NAME = 'app-v1';
const PRECACHE_URLS = [
  '/',
  '/index.html',
  '/styles.css',
  '/app.js',
  '/offline.html'
];

self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => {
      console.log('Pre-caching assets');
      return cache.addAll(PRECACHE_URLS);
    })
  );
  self.skipWaiting();
});

Clean Old Caches on Activate

sw.js
const CACHE_NAME = 'app-v2'; // Bumped version

self.addEventListener('activate', (event) => {
  const ALLOWED_CACHES = [CACHE_NAME];

  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames
          .filter((name) => !ALLOWED_CACHES.includes(name))
          .map((name) => {
            console.log('Deleting old cache:', name);
            return caches.delete(name);
          })
      );
    }).then(() => self.clients.claim())
  );
});

Versioned Cache Upgrade Pattern

sw.js
const VERSION = 'v3';
const STATIC_CACHE = `static-${VERSION}`;
const DYNAMIC_CACHE = `dynamic-${VERSION}`;

self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(STATIC_CACHE).then((cache) => cache.addAll([
      '/',
      '/app.js'
    ]))
  );
});

self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((keys) =>
      Promise.all(
        keys
          .filter((k) => !k.endsWith(VERSION))
          .map((k) => caches.delete(k))
      )
    )
  );
  self.clients.claim();
});

Fetch Event Handling

The fetch event intercepts all network requests made by the page. Call event.respondWith() to provide a custom response.

Basic Fetch Handler

sw.js
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((cachedResponse) => {
      // Return cached version or fetch from network
      return cachedResponse || fetch(event.request);
    })
  );
});

Non-GET Requests (Pass-through)

sw.js
self.addEventListener('fetch', (event) => {
  // Only intercept GET requests
  if (event.request.method !== 'GET') return;

  event.respondWith(
    caches.match(event.request).then((cached) => {
      return cached || fetch(event.request);
    })
  );
});

Offline Fallback

sw.js
self.addEventListener('fetch', (event) => {
  event.respondWith(
    fetch(event.request).catch(() => {
      // For navigation requests, show offline page
      if (event.request.mode === 'navigate') {
        return caches.match('/offline.html');
      }
      // For other requests, try the cache
      return caches.match(event.request);
    })
  );
});

Request Routing by URL

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

  // API requests → network-first
  if (url.pathname.startsWith('/api/')) {
    event.respondWith(networkFirst(event.request));
    return;
  }

  // Static assets → cache-first
  if (url.pathname.match(/\.(js|css|png|jpg|svg|woff2)$/)) {
    event.respondWith(cacheFirst(event.request));
    return;
  }

  // Everything else → stale-while-revalidate
  event.respondWith(staleWhileRevalidate(event.request));
});

Caching Strategies

Cache First Static Assets

Check the cache first. If found, return it. Otherwise fetch from network and cache the response.

sw.js
async function cacheFirst(request) {
  const cached = await caches.match(request);
  if (cached) return cached;

  try {
    const response = await fetch(request);
    if (response.ok) {
      const cache = await caches.open('static-v1');
      cache.put(request, response.clone());
    }
    return response;
  } catch (error) {
    // Optional: return a fallback response
    return new Response('Offline', { status: 503 });
  }
}

Network First API / Dynamic

Try the network first. If it fails, fall back to cache. Best for always-fresh data.

sw.js
async function networkFirst(request) {
  try {
    const response = await fetch(request);
    if (response.ok) {
      const cache = await caches.open('dynamic-v1');
      cache.put(request, response.clone());
    }
    return response;
  } catch (error) {
    const cached = await caches.match(request);
    if (cached) return cached;
    return new Response(JSON.stringify({ error: 'Offline' }), {
      headers: { 'Content-Type': 'application/json' },
      status: 503
    });
  }
}

Stale While Revalidate Best UX

Return cached response immediately (if available), then fetch and update the cache in the background. User gets fast response + fresh data on next visit.

sw.js
async function staleWhileRevalidate(request) {
  const cache = await caches.open('dynamic-v1');
  const cached = await cache.match(request);

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

  // Return cache immediately, or wait for network if not cached
  return cached || fetchPromise;
}

Network Only Non-cacheable

sw.js
async function networkOnly(request) {
  return fetch(request);
}

Cache Only Pre-cached

sw.js
async function cacheOnly(request) {
  const cached = await caches.match(request);
  return cached || new Response('Not found', { status: 404 });
}

Strategy Comparison

Strategy Freshness Speed Offline Best For
Cache First Low Fastest Yes Static assets, fonts, images
Network First Highest Slower Yes API calls, user data
Stale-While-Revalidate Medium Fast Yes Mixed content, best UX

Cache API

The Cache API is available in both the service worker and the main thread (via window.caches).

Open & Add

cache-api.js
const cache = await caches.open('my-cache');

// Add a single URL
await cache.add('/styles.css');

// Add multiple URLs
await cache.addAll(['/app.js', '/logo.png', '/manifest.json']);

// Add with a custom request
await cache.put(
  new Request('/api/data', { headers: { 'Authorization': 'Bearer ...' } }),
  new Response(JSON.stringify({ hello: 'world' }), {
    headers: { 'Content-Type': 'application/json' }
  })
);

Read & Delete

cache-api.js
// Match a specific request
const response = await caches.match('/styles.css');
if (response) {
  const text = await response.text();
  console.log('Cached CSS length:', text.length);
}

// Match within a named cache
const cache = await caches.open('my-cache');
const entry = await cache.match('/api/data');

// Delete a specific entry
await cache.delete('/old-page.html');

// Delete an entire cache
await caches.delete('my-cache');

List All Caches & Entries

cache-api.js
// List all cache names
const names = await caches.keys();
console.log('Caches:', names);

// List all entries in a cache
const cache = await caches.open('my-cache');
const requests = await cache.keys();
console.log(`Entries (${requests.length}):`);
for (const req of requests) {
  console.log(`  ${req.method} ${req.url}`);
}

// Get cache storage estimate
const estimate = await navigator.storage.estimate();
console.log(`Used: ${(estimate.usage / 1024 / 1024).toFixed(2)} MB`);
console.log(`Quota: ${(estimate.quota / 1024 / 1024).toFixed(0)} MB`);

From the Main Thread

main.js
// You can use the Cache API outside the service worker
async function prefetchPage(url) {
  const cache = await caches.open('prefetch');
  const response = await fetch(url);
  await cache.put(url, response);
  console.log('Prefetched:', url);
}

// Usage: prefetch pages the user is likely to visit
document.querySelectorAll('a[rel="prefetch"]').forEach((link) => {
  link.addEventListener('mouseenter', () => prefetchPage(link.href), { once: true });
});

Push Notifications

Push requires a VAPID key pair and a push service subscription. The service worker listens for push events.

Request Permission & Subscribe

main.js
const VAPID_PUBLIC_KEY = 'BEl62iUYgUivxIkv69yViEuiBIa-...(your public key)';

async function subscribeToPush() {
  const permission = await Notification.requestPermission();
  if (permission !== 'granted') {
    console.log('Notification permission denied');
    return;
  }

  const registration = await navigator.serviceWorker.ready;
  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.toJSON())
  });

  console.log('Subscribed:', subscription.endpoint);
}

// 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 = atob(base64);
  return Uint8Array.from([...rawData].map((c) => c.charCodeAt(0)));
}

Handle Push Event (SW)

sw.js
self.addEventListener('push', (event) => {
  let data = { title: 'Notification', body: 'You have a new message.' };

  if (event.data) {
    try {
      data = event.data.json();
    } catch {
      data.body = event.data.text();
    }
  }

  const options = {
    body: data.body,
    icon: '/icon-192.png',
    badge: '/badge-72.png',
    vibrate: [100, 50, 100],
    data: {
      url: data.url || '/'
    },
    actions: [
      { action: 'open', title: 'Open' },
      { action: 'dismiss', title: 'Dismiss' }
    ]
  };

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

Handle Notification Click (SW)

sw.js
self.addEventListener('notificationclick', (event) => {
  event.notification.close();

  if (event.action === 'dismiss') return;

  const targetUrl = event.notification.data?.url || '/';

  event.waitUntil(
    self.clients.matchAll({ type: 'window', includeUncontrolled: true })
      .then((clients) => {
        // Focus existing window if open
        for (const client of clients) {
          if (client.url.includes(targetUrl) && 'focus' in client) {
            return client.focus();
          }
        }
        // Otherwise open a new window
        return self.clients.openWindow(targetUrl);
      })
  );
});

Unsubscribe

main.js
async function unsubscribeFromPush() {
  const registration = await navigator.serviceWorker.ready;
  const subscription = await registration.pushManager.getSubscription();

  if (subscription) {
    await subscription.unsubscribe();
    // Notify server
    await fetch('/api/push/unsubscribe', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ endpoint: subscription.endpoint })
    });
    console.log('Unsubscribed');
  }
}

Background Sync

Background Sync lets you defer actions until the user has stable connectivity. Perfect for offline form submissions, analytics, and queued operations.

Register a Sync Event

main.js
async function submitForm(formData) {
  if ('serviceWorker' in navigator && 'SyncManager' in window) {
    // Store the data in IndexedDB for the SW to pick up
    await storeInIndexedDB('outbox', formData);

    const registration = await navigator.serviceWorker.ready;
    await registration.sync.register('submit-form');
    console.log('Sync registered — will fire when online');
  } else {
    // Fallback: just try to send now
    await fetch('/api/submit', {
      method: 'POST',
      body: JSON.stringify(formData)
    });
  }
}

Handle Sync Event (SW)

sw.js
self.addEventListener('sync', (event) => {
  if (event.tag === 'submit-form') {
    event.waitUntil(submitFormData());
  }

  if (event.tag === 'sync-analytics') {
    event.waitUntil(flushAnalytics());
  }
});

async function submitFormData() {
  const items = await getAllFromIndexedDB('outbox');

  for (const item of items) {
    try {
      const response = await fetch('/api/submit', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(item)
      });

      if (response.ok) {
        await deleteFromIndexedDB('outbox', item.id);
      }
    } catch (error) {
      // Will retry on next sync
      console.error('Submit failed:', error);
      throw error;
    }
  }
}

Periodic Background Sync Chrome 80+

main.js
async function registerPeriodicSync() {
  const registration = await navigator.serviceWorker.ready;

  // Check if periodic sync is supported
  if ('periodicSync' in registration) {
    const status = await navigator.permissions.query({
      name: 'periodic-background-sync'
    });

    if (status.state === 'granted') {
      await registration.periodicSync.register('update-content', {
        minInterval: 24 * 60 * 60 * 1000 // Once per day
      });
      console.log('Periodic sync registered');
    }
  }
}

// In sw.js:
self.addEventListener('periodicsync', (event) => {
  if (event.tag === 'update-content') {
    event.waitUntil(updateCacheInBackground());
  }
});

async function updateCacheInBackground() {
  const cache = await caches.open('content-v1');
  await cache.addAll(['/latest-data.json']);
}

IndexedDB Helper (for Sync)

idb-helper.js
// Minimal IndexedDB wrapper for offline queuing
function openDB() {
  return new Promise((resolve, reject) => {
    const req = indexedDB.open('sw-sync-db', 1);
    req.onupgradeneeded = () => {
      const db = req.result;
      if (!db.objectStoreNames.contains('outbox')) {
        db.createObjectStore('outbox', { keyPath: 'id', autoIncrement: true });
      }
    };
    req.onsuccess = () => resolve(req.result);
    req.onerror = () => reject(req.error);
  });
}

async function storeInIndexedDB(storeName, data) {
  const db = await openDB();
  const tx = db.transaction(storeName, 'readwrite');
  tx.objectStore(storeName).add(data);
  return new Promise((resolve, reject) => {
    tx.oncomplete = resolve;
    tx.onerror = () => reject(tx.error);
  });
}

async function getAllFromIndexedDB(storeName) {
  const db = await openDB();
  const tx = db.transaction(storeName, 'readonly');
  return new Promise((resolve) => {
    const req = tx.objectStore(storeName).getAll();
    req.onsuccess = () => resolve(req.result);
  });
}

async function deleteFromIndexedDB(storeName, id) {
  const db = await openDB();
  const tx = db.transaction(storeName, 'readwrite');
  tx.objectStore(storeName).delete(id);
  return new Promise((resolve) => {
    tx.oncomplete = resolve;
  });
}
Copied to clipboard