Skip to content
All essays
CraftMarch 25, 202425 min

Service Workers Complete Guide

Master Service Workers for offline functionality, push notifications, and background sync

Ü
Ümit Uz
Mobile & Full Stack Developer

Service Workers enable powerful offline experiences and background operations. Let's master them from registration to advanced caching strategies.

What are Service Workers?

Service Workers are JavaScript files that run independently of your web page, enabling:

  • Offline functionality
  • Background sync
  • Push notifications
  • Network request interception
  • Custom caching strategies

Key Characteristics:

  • Run in a separate thread
  • Are event-driven
  • Have no DOM access
  • Use Promises heavily
  • Can terminate when idle

Registration

Basic Registration

typescript
// Register Service Worker
if ('serviceWorker' in navigator) {
  window.addEventListener('load', () => {
    navigator.serviceWorker.register('/sw.js')
      .then((registration) => {
        console.log('SW registered:', registration);

        // Check for updates
        registration.addEventListener('updatefound', () => {
          const newWorker = registration.installing;
          if (newWorker) {
            newWorker.addEventListener('statechange', () => {
              if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
                // New version available
                if (confirm('New version available. Reload?')) {
                  window.location.reload();
                }
              }
            });
          }
        });
      })
      .catch((error) => {
        console.error('SW registration failed:', error);
      });
  });
}

Unregister Service Worker

typescript
navigator.serviceWorker.getRegistrations().then((registrations) => {
  registrations.forEach((registration) => {
    registration.unregister();
  });
});

Service Worker Lifecycle

Install Event

javascript
// sw.js
self.addEventListener('install', (event) => {
  console.log('SW installing...');

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

  // Become active immediately
  self.skipWaiting();
});

Activate Event

javascript
self.addEventListener('activate', (event) => {
  console.log('SW activating...');

  // Clean up old caches
  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames
          .filter((cacheName) => {
            return cacheName !== 'static-v1' && cacheName !== 'dynamic-v1';
          })
          .map((cacheName) => {
            return caches.delete(cacheName);
          })
      );
    })
  );

  // Take control immediately
  return self.clients.claim();
});

Fetch Event

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

Caching Strategies

Cache First - Fall Back to Network

javascript
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((cachedResponse) => {
      return cachedResponse || fetch(event.request);
    })
  );
});

Use for: Static assets (CSS, JS, images)

Network First - Fall Back to Cache

javascript
self.addEventListener('fetch', (event) => {
  event.respondWith(
    fetch(event.request)
      .then((response) => {
        // Clone response before caching
        const responseClone = response.clone();
        caches.open('dynamic-v1').then((cache) => {
          cache.put(event.request, responseClone);
        });
        return response;
      })
      .catch(() => {
        return caches.match(event.request);
      })
  );
});

Use for: HTML, API requests

Cache Only

javascript
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((cachedResponse) => {
      if (cachedResponse) {
        return cachedResponse;
      }
      // Return offline page if not cached
      return caches.match('/offline.html');
    })
  );
});

Use for: Offline-only assets

Network Only

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

Use for: Non-cacheable requests (analytics, etc.)

Stale While Revalidate

javascript
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((cachedResponse) => {
      const fetchPromise = fetch(event.request).then((networkResponse) => {
        caches.open('dynamic-v1').then((cache) => {
          cache.put(event.request, networkResponse.clone());
        });
        return networkResponse;
      });

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

Use for: Frequently updated content

Route-Based Strategies

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

  // Different strategies for different routes
  if (url.pathname.startsWith('/api/')) {
    // Network first for API
    event.respondWith(networkFirst(event.request));
  } else if (url.pathname.match(/\.(css|js|png|jpg)$/)) {
    // Cache first for static assets
    event.respondWith(cacheFirst(event.request));
  } else {
    // Stale while revalidate for pages
    event.respondWith(staleWhileRevalidate(event.request));
  }
});

function networkFirst(request) {
  return fetch(request)
    .then((response) => {
      const clone = response.clone();
      caches.open('api-v1').then((cache) => cache.put(request, clone));
      return response;
    })
    .catch(() => caches.match(request));
}

function cacheFirst(request) {
  return caches.match(request).then((cached) => {
    return cached || fetch(request).then((response) => {
      const clone = response.clone();
      caches.open('static-v1').then((cache) => cache.put(request, clone));
      return response;
    });
  });
}

function staleWhileRevalidate(request) {
  return caches.match(request).then((cached) => {
    const fetchPromise = fetch(request).then((response) => {
      const clone = response.clone();
      caches.open('pages-v1').then((cache) => cache.put(request, clone));
      return response;
    });
    return cached || fetchPromise;
  });
}

Cache Management

Cache Versioning

javascript
const CACHE_VERSION = 'v2';
const CACHE_NAME = `my-app-${CACHE_VERSION}`;

const urlsToCache = [
  '/',
  '/index.html',
  '/styles.css',
  '/app.js'
];

self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => {
      return cache.addAll(urlsToCache);
    })
  );
});

self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames.map((cacheName) => {
          if (cacheName !== CACHE_NAME) {
            return caches.delete(cacheName);
          }
        })
      );
    })
  );
});

Dynamic Cache Updates

javascript
// Cache API responses
self.addEventListener('fetch', (event) => {
  if (event.request.url.startsWith('/api/')) {
    event.respondWith(
      fetch(event.request)
        .then((response) => {
          // Only cache successful responses
          if (response.status === 200) {
            const clone = response.clone();
            caches.open('api-data').then((cache) => {
              cache.put(event.request, clone);
            });
          }
          return response;
        })
        .catch(() => {
          return caches.match(event.request);
        })
    );
  }
});

Cache Expiration

javascript
// Implement cache expiration
const CACHE_EXPIRATION = {
  '/api/': 5 * 60 * 1000, // 5 minutes
  '/images/': 7 * 24 * 60 * 60 * 1000, // 7 days
  '/static/': 30 * 24 * 60 * 60 * 1000 // 30 days
};

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

  event.respondWith(
    caches.match(event.request).then((cachedResponse) => {
      if (cachedResponse) {
        const cachedTime = parseInt(cachedResponse.headers.get('date') || '0');
        const expiration = CACHE_EXPIRATION[cacheKey];

        if (expiration && Date.now() - cachedTime > expiration) {
          // Cache expired, fetch from network
          return fetch(event.request).then((response) => {
            const clone = response.clone();
            clone.headers.set('date', Date.now().toString());
            caches.open(CACHE_NAME).then((cache) => {
              cache.put(event.request, clone);
            });
            return response;
          });
        }
      }
      return cachedResponse || fetch(event.request);
    })
  );
});

function getCacheKey(pathname) {
  if (pathname.startsWith('/api/')) return '/api/';
  if (pathname.startsWith('/images/')) return '/images/';
  if (pathname.startsWith('/static/')) return '/static/';
  return null;
}

Background Sync

Register Sync Event

typescript
// Request background sync
navigator.serviceWorker.ready.then((registration) => {
  registration.sync.register('sync-messages');
});

Handle Sync Event

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

async function syncMessages() {
  try {
    // Get unsent messages from IndexedDB
    const messages = await getUnsentMessages();

    // Send to server
    await Promise.all(messages.map((message) => {
      return fetch('/api/messages', {
        method: 'POST',
        body: JSON.stringify(message)
      });
    }));

    // Clear IndexedDB
    await clearUnsentMessages();
  } catch (error) {
    console.error('Sync failed:', error);
    // Browser will retry automatically
  }
}

Push Notifications

Request Permission

typescript
async function requestNotificationPermission() {
  const permission = await Notification.requestPermission();
  if (permission === 'granted') {
    console.log('Notification permission granted');
    // Subscribe to push notifications
    subscribeToPush();
  }
}

Subscribe to Push

typescript
async function subscribeToPush() {
  const registration = await navigator.serviceWorker.ready;
  const subscription = await registration.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: urlBase64ToUint8Array('YOUR_PUBLIC_KEY')
  });

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

function urlBase64ToUint8Array(base64String: string) {
  const padding = '='.repeat((4 - base64String.length % 4) % 4);
  const base64 = (base64String + padding)
    .replace(/-/g, '+')
    .replace(/_/g, '/');

  const rawData = window.atob(base64);
  const outputArray = new Uint8Array(rawData.length);

  for (let i = 0; i < rawData.length; ++i) {
    outputArray[i] = rawData.charCodeAt(i);
  }
  return outputArray;
}

Handle Push Event

javascript
// sw.js
self.addEventListener('push', (event) => {
  const options = {
    body: event.data ? event.data.text() : 'New message',
    icon: '/icons/icon-192.png',
    badge: '/icons/badge-72.png',
    vibrate: [200, 100, 200],
    data: {
      dateOfArrival: Date.now(),
      primaryKey: 1
    }
  };

  event.waitUntil(
    self.registration.showNotification('Push Notification', options)
  );
});

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

Offline Page

javascript
// sw.js
self.addEventListener('fetch', (event) => {
  event.respondWith(
    fetch(event.request)
      .catch(() => {
        // Return offline page when network fails
        return caches.match('/offline.html');
      })
  );
});
html
<!-- offline.html -->
<!DOCTYPE html>
<html>
<head>
  <title>You're Offline</title>
  <style>
    body {
      display: flex;
      justify-content: center;
      align-items: center;
      height: 100vh;
      font-family: Arial, sans-serif;
      text-align: center;
    }
  </style>
</head>
<body>
  <div>
    <h1>You're Offline</h1>
    <p>Please check your internet connection.</p>
    <button onclick="location.reload()">Try Again</button>
  </div>
</body>
</html>

Debugging

Chrome DevTools

  1. 1Application Tab

- Service Workers: View status, unregister, debug - Cache Storage: View cached resources - Clear Storage: Clear all data

  1. 1Network Tab

- Disable cache: Test Service Worker behavior - Offline mode: Test offline functionality

Logging

javascript
// sw.js
self.addEventListener('fetch', (event) => {
  console.log('Fetching:', event.request.url);
  event.respondWith(
    caches.match(event.request).then((cached) => {
      if (cached) {
        console.log('From cache:', event.request.url);
        return cached;
      }
      console.log('From network:', event.request.url);
      return fetch(event.request);
    })
  );
});

Best Practices

  1. 1Always Use HTTPS: Service Workers only work on secure contexts
  2. 2Version Your Caches: Prevent cache conflicts
  3. 3Clean Up Old Caches: Remove outdated cache versions
  4. 4Handle Errors Gracefully: Provide fallback content
  5. 5Test Offline Functionality: Regularly test offline behavior
  6. 6Update Service Worker: Notify users of updates
  7. 7Use Appropriate Strategies: Choose right caching strategy per resource type
  8. 8Monitor Performance: Track cache hit rates and performance

Performance Tips

  1. 1Preload Critical Resources: Cache important assets during install
  2. 2Use Cache Headers: Respect server cache headers
  3. 3Limit Cache Size: Prevent storage quota issues
  4. 4Compress Responses: Reduce bandwidth usage
  5. 5Cache API Responses: Reduce server load

Common Pitfalls

  1. 1Not Skipping Waiting: Users may see old content
  2. 2Not Handling Errors: Poor offline experience
  3. 3Over-caching: Wasted storage space
  4. 4Not Cleaning Up: Old caches taking space
  5. 5Ignoring Update Cycle: Users stuck on old versions

Conclusion

Service Workers enable powerful offline experiences and background operations. Master caching strategies, implement proper error handling, and always test thoroughly. The result is a faster, more resilient web application that works everywhere.

Related essays

Next essay
WebSockets Real-time Communication