Service Workers Implementation

Module 25: Frontend Frameworks & State Management

Introduction to Service Workers

Service workers act as a programmable network proxy between your web application and the network. They are a type of web worker that runs in the background, separate from the main browser thread, and can intercept network requests, cache resources, and enable offline functionality for web applications.

Think of a service worker as a diligent personal assistant who stands between you and the outside world. This assistant can:

graph TD A[Browser] -- "1. Request" --> B[Service Worker] B -- "2a. Network Request" --> C[Network] C -- "2b. Response" --> B B -- "3a. Cache Response" --> D[(Cache Storage)] D -- "3b. Retrieve Cached Response" --> B B -- "4. Response" --> A style B fill:#f9f,stroke:#333,stroke-width:2px

Service Worker Lifecycle

Service workers have a distinct lifecycle that's important to understand when implementing them:

flowchart TD R[Registration] --> I[Installation] I --> A[Activation] A --> ID[Idle] ID --> T[Terminated] ID --> F[Fetch/Message Events] F --> ID style A fill:#bbf,stroke:#333,stroke-width:2px

Registration

The first step is to register a service worker. This tells the browser where your service worker JavaScript file is located:


// Check if service workers are supported
if ('serviceWorker' in navigator) {
  window.addEventListener('load', () => {
    navigator.serviceWorker.register('/service-worker.js')
      .then(registration => {
        console.log('Service Worker registered with scope:', registration.scope);
      })
      .catch(error => {
        console.error('Service Worker registration failed:', error);
      });
  });
}
            

Installation

The installation event is triggered when the service worker is first discovered and installed. This is typically where you'll cache static assets:


const CACHE_NAME = 'my-site-cache-v1';
const urlsToCache = [
  '/',
  '/styles/main.css',
  '/scripts/main.js',
  '/images/logo.png'
];

self.addEventListener('install', event => {
  // Perform install steps
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then(cache => {
        console.log('Opened cache');
        return cache.addAll(urlsToCache);
      })
  );
});
            

Activation

The activation event is fired when the service worker starts controlling the page. This is a good time to clean up old caches:


self.addEventListener('activate', event => {
  const cacheWhitelist = ['my-site-cache-v1'];
  
  event.waitUntil(
    caches.keys().then(cacheNames => {
      return Promise.all(
        cacheNames.map(cacheName => {
          if (cacheWhitelist.indexOf(cacheName) === -1) {
            // If this cache name isn't in the whitelist, delete it
            return caches.delete(cacheName);
          }
        })
      );
    })
  );
});
            

Fetch Event

Once activated, the service worker can intercept fetch events. This is where you implement your caching strategy:


self.addEventListener('fetch', event => {
  event.respondWith(
    caches.match(event.request)
      .then(response => {
        // Cache hit - return response
        if (response) {
          return response;
        }
        
        // Clone the request because it's a one-time use stream
        const fetchRequest = event.request.clone();
        
        return fetch(fetchRequest).then(response => {
          // Check if we received a valid response
          if(!response || response.status !== 200 || response.type !== 'basic') {
            return response;
          }
          
          // Clone the response because it's a one-time use stream
          const responseToCache = response.clone();
          
          caches.open(CACHE_NAME)
            .then(cache => {
              cache.put(event.request, responseToCache);
            });
            
          return response;
        });
      })
    );
});
            

Caching Strategies

There are several caching strategies you can implement with service workers:

Cache First (Offline First)

Check the cache first, and only go to the network if the resource isn't cached. Perfect for static assets that don't change often.


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

Network First

Try the network first, and fall back to the cache if the network request fails. Ideal for content that needs to be up-to-date but should still work offline.


self.addEventListener('fetch', event => {
  event.respondWith(
    fetch(event.request)
      .then(response => {
        // Clone the response to store in cache
        let responseClone = response.clone();
        caches.open(CACHE_NAME)
          .then(cache => {
            cache.put(event.request, responseClone);
          });
        return response;
      })
      .catch(() => {
        return caches.match(event.request);
      })
  );
});
            

Stale While Revalidate

Return cached version immediately (if available), then update the cache with a fresh network response for next time. Great for balancing speed and freshness.


self.addEventListener('fetch', event => {
  event.respondWith(
    caches.open(CACHE_NAME).then(cache => {
      return cache.match(event.request).then(response => {
        const fetchPromise = fetch(event.request).then(networkResponse => {
          cache.put(event.request, networkResponse.clone());
          return networkResponse;
        });
        // Return the cache response if we have one, otherwise wait for the network response
        return response || fetchPromise;
      });
    })
  );
});
            

Cache Only

Only use the cache, never go to network. Useful for assets that are guaranteed to be cached during installation.


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

Network Only

Only use the network, never check the cache. Good for non-GET requests or requests that should always be fresh.


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

Real-World Implementation Examples

E-commerce PWA

For an e-commerce site, you might use different strategies for different types of requests:


self.addEventListener('fetch', event => {
  const url = new URL(event.request.url);
  
  // Static assets: Cache First
  if (url.pathname.startsWith('/images/') || 
      url.pathname.startsWith('/styles/') || 
      url.pathname.startsWith('/scripts/')) {
    event.respondWith(
      caches.match(event.request)
        .then(response => {
          return response || fetch(event.request)
            .then(response => {
              let responseClone = response.clone();
              caches.open('static-assets')
                .then(cache => {
                  cache.put(event.request, responseClone);
                });
              return response;
            });
        })
    );
  }
  
  // Product details: Network First
  else if (url.pathname.startsWith('/products/')) {
    event.respondWith(
      fetch(event.request)
        .then(response => {
          let responseClone = response.clone();
          caches.open('product-cache')
            .then(cache => {
              cache.put(event.request, responseClone);
            });
          return response;
        })
        .catch(() => {
          return caches.match(event.request);
        })
    );
  }
  
  // Checkout: Network Only
  else if (url.pathname.startsWith('/checkout/') || url.pathname.startsWith('/cart/')) {
    event.respondWith(fetch(event.request));
  }
  
  // Default: Stale While Revalidate
  else {
    event.respondWith(
      caches.open('default-cache').then(cache => {
        return cache.match(event.request).then(response => {
          const fetchPromise = fetch(event.request).then(networkResponse => {
            cache.put(event.request, networkResponse.clone());
            return networkResponse;
          });
          return response || fetchPromise;
        });
      })
    );
  }
});
            

Background Sync

Service workers can also perform background synchronization, allowing failed requests to be retried when the user regains connectivity.

Example: Message Sending in a Chat App

When a user sends a message while offline:


// In your web app
function sendMessage(message) {
  if (!navigator.onLine) {
    // Save the message to IndexedDB
    saveMessageToIndexedDB(message)
      .then(() => {
        // Register a sync event
        return navigator.serviceWorker.ready;
      })
      .then(registration => {
        return registration.sync.register('outbox-sync');
      })
      .catch(err => console.error('Background sync registration failed:', err));
    
    return;
  }
  
  // Otherwise, send as normal
  return fetch('/api/messages', {
    method: 'POST',
    body: JSON.stringify(message),
    headers: {
      'Content-Type': 'application/json'
    }
  });
}

// In your service worker
self.addEventListener('sync', event => {
  if (event.tag === 'outbox-sync') {
    event.waitUntil(
      // Get all messages from IndexedDB
      getMessagesFromIndexedDB()
        .then(messages => {
          // Send each message
          return Promise.all(
            messages.map(message => {
              return fetch('/api/messages', {
                method: 'POST',
                body: JSON.stringify(message),
                headers: {
                  'Content-Type': 'application/json'
                }
              }).then(response => {
                if (response.ok) {
                  // If successful, remove from IndexedDB
                  return removeMessageFromIndexedDB(message.id);
                }
                throw new Error('Failed to send message');
              });
            })
          );
        })
    );
  }
});
            

Push Notifications

Service workers can receive push events and display notifications, even when the user isn't actively using your site.


// Request permission
function requestNotificationPermission() {
  return Notification.requestPermission().then(permission => {
    if (permission === 'granted') {
      subscribeUserToPush();
    }
  });
}

// Subscribe the user to push notifications
function subscribeUserToPush() {
  navigator.serviceWorker.ready
    .then(registration => {
      const subscribeOptions = {
        userVisibleOnly: true,
        applicationServerKey: urlBase64ToUint8Array('YOUR_PUBLIC_VAPID_KEY')
      };

      return registration.pushManager.subscribe(subscribeOptions);
    })
    .then(pushSubscription => {
      // Send the subscription to your server
      return fetch('/api/subscriptions', {
        method: 'POST',
        body: JSON.stringify(pushSubscription),
        headers: {
          'Content-Type': 'application/json'
        }
      });
    });
}

// In your service worker
self.addEventListener('push', event => {
  if (event.data) {
    const data = event.data.json();
    
    const options = {
      body: data.body,
      icon: '/images/icon.png',
      badge: '/images/badge.png',
      data: {
        url: data.url
      }
    };
    
    event.waitUntil(
      self.registration.showNotification(data.title, options)
    );
  }
});

// Handle notification clicks
self.addEventListener('notificationclick', event => {
  event.notification.close();
  
  if (event.notification.data && event.notification.data.url) {
    event.waitUntil(
      clients.openWindow(event.notification.data.url)
    );
  }
});
            

Common Challenges and Solutions

Updating Service Workers

Service workers persist until they're explicitly updated. One common pattern is to include a version number in your cache name:


const CACHE_VERSION = 'v2';
const CACHE_NAME = `my-site-cache-${CACHE_VERSION}`;

// Then in the activate event, delete old caches
self.addEventListener('activate', event => {
  event.waitUntil(
    caches.keys().then(cacheNames => {
      return Promise.all(
        cacheNames.map(cacheName => {
          if (cacheName.startsWith('my-site-cache-') && cacheName !== CACHE_NAME) {
            return caches.delete(cacheName);
          }
        })
      );
    })
  );
});
            

Debugging Service Workers

Service workers can be challenging to debug. Chrome DevTools provides dedicated tools for service worker debugging:

Handling Cross-Origin Requests

By default, service workers can only intercept requests to their own origin. For cross-origin requests, you need CORS support:


// Add mode: 'cors' to fetch requests
fetch('https://api.example.com/data', { mode: 'cors' })
  .then(response => {
    // Handle response
  });
            

Best Practices

Practice Activities

Activity 1: Basic Service Worker Implementation

Create a simple web page with HTML, CSS, and JavaScript, then add a service worker that caches the static assets and enables offline access.

Activity 2: Multiple Caching Strategies

Modify your service worker to use different caching strategies for different types of content (e.g., cache-first for static resources, network-first for API requests).

Activity 3: Background Sync

Create a form that saves submissions to IndexedDB when offline and uses background sync to send them when connectivity is restored.

Activity 4: Service Worker Update Process

Implement a versioning system for your service worker and create a user interface that notifies users when a new version is available and prompts them to refresh.

Additional Resources