JavaScript Service Workers

Service Workers are a special type of Web Worker that act as proxy servers between web applications, the browser, and the network. They enable powerful features like offline functionality, background sync, push notifications, and resource caching, making them a fundamental technology for Progressive Web Apps (PWAs).

Service Workers vs Web Workers: While both run JavaScript in background threads, Service Workers have a different purpose and lifecycle:

  • Web Workers are for running CPU-intensive tasks without blocking the UI thread and are tied to a specific page.
  • Service Workers act as network proxies, can control multiple pages, and continue to exist even after a page is closed.

Service Worker Lifecycle

Service Workers have a complex lifecycle that's important to understand:

  1. Registration: The browser registers the Service Worker script.
  2. Installation: The Service Worker installs and typically caches static assets.
  3. Activation: The Service Worker activates and can control pages.
  4. Idle: The Service Worker goes idle when not in use.
  5. Termination: The browser may terminate the Service Worker to conserve memory.
  6. Update: When a new version of the Service Worker is detected, it installs in the background but doesn't activate until all controlled pages are closed.
// Register a Service Worker
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);
      });
  });
}

Creating a Basic Service Worker

Let's create a simple Service Worker that caches static assets and serves them when offline:

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

// Installation - Cache static assets
self.addEventListener('install', event => {
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then(cache => {
        console.log('Opened cache');
        return cache.addAll(urlsToCache);
      })
  );
});

// Activation - Clean up old caches
self.addEventListener('activate', event => {
  const cacheWhitelist = [CACHE_NAME];
  event.waitUntil(
    caches.keys().then(cacheNames => {
      return Promise.all(
        cacheNames.map(cacheName => {
          if (cacheWhitelist.indexOf(cacheName) === -1) {
            // Delete old caches
            return caches.delete(cacheName);
          }
        })
      );
    })
  );
});

// Fetch - Serve from cache, falling back to network
self.addEventListener('fetch', event => {
  event.respondWith(
    caches.match(event.request)
      .then(response => {
        // Cache hit - return the response from the cached version
        if (response) {
          return response;
        }
        
        // Not in cache - fetch from network
        return fetch(event.request)
          .then(response => {
            // Check if we received a valid response
            if (!response || response.status !== 200 || response.type !== 'basic') {
              return response;
            }
            
            // Clone the response
            const responseToCache = response.clone();
            
            // Add to cache
            caches.open(CACHE_NAME)
              .then(cache => {
                cache.put(event.request, responseToCache);
              });
              
            return response;
          });
      })
  );
});

Important: Service Workers can only be registered on pages served over HTTPS (except for localhost during development). This is a security requirement.

Caching Strategies

Different caching strategies can be implemented depending on your application's needs:

Strategy Description Best For
Cache First Check cache first, fall back to network Static assets that rarely change
Network First Try network first, fall back to cache Content that updates frequently
Stale While Revalidate Return from cache, then update cache from network Balance of performance and freshness
Cache Only Only serve from cache Assets that never change
Network Only Only serve from network Dynamic content that must be fresh

Network First Strategy Example

self.addEventListener('fetch', event => {
  event.respondWith(
    fetch(event.request)
      .then(response => {
        // If we got a valid response, clone it and put it in the cache
        if (response && response.status === 200) {
          const responseToCache = response.clone();
          caches.open(CACHE_NAME)
            .then(cache => {
              cache.put(event.request, responseToCache);
            });
        }
        return response;
      })
      .catch(() => {
        // If network fails, try to get it from the cache
        return caches.match(event.request);
      })
  );
});

Stale While Revalidate Strategy Example

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 the cached response immediately, or wait for network if not in cache
        return cachedResponse || fetchPromise;
      });
    })
  );
});

Background Sync

Background Sync allows web applications to defer actions until the user has stable connectivity. This is ideal for ensuring that data is sent to the server, even if the user goes offline.

// In your web app
function sendDataToServer() {
  if (!navigator.onLine) {
    // Register for background sync if offline
    navigator.serviceWorker.ready
      .then(registration => {
        return registration.sync.register('mySync');
      })
      .catch(err => {
        console.error('Background sync registration failed:', err);
      });
    
    // Save data to IndexedDB for later
    saveToIndexedDB(data);
  } else {
    // Send directly if online
    fetch('/api/endpoint', {
      method: 'POST',
      body: JSON.stringify(data)
    });
  }
}

// In your service worker
self.addEventListener('sync', event => {
  if (event.tag === 'mySync') {
    event.waitUntil(
      // Get data from IndexedDB and send it
      getDataFromIndexedDB().then(data => {
        return fetch('/api/endpoint', {
          method: 'POST',
          body: JSON.stringify(data)
        });
      })
    );
  }
});

Push Notifications

Service Workers can receive push messages from a server and display notifications to the user, even when the web app is not open.

Requesting Permission and Subscribing

// Request permission and subscribe to push notifications
function subscribeUserToPush() {
  return navigator.serviceWorker.ready
    .then(registration => {
      const subscribeOptions = {
        userVisibleOnly: true,
        applicationServerKey: urlBase64ToUint8Array(
          'BEL4_UkXyIGXq9UDJQ1P0q9nPj4oRCki8yBQUXQBTMDjHLQnrzP7xKoFQWn_hSey9KYgNnpnqZJWBYnF-u5RZ8U'
        )
      };
      
      return registration.pushManager.subscribe(subscribeOptions);
    })
    .then(pushSubscription => {
      // Send the subscription to your server
      return fetch('/api/save-subscription', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json'
        },
        body: JSON.stringify(pushSubscription)
      });
    });
}

// Helper function to convert base64 to Uint8Array
function urlBase64ToUint8Array(base64String) {
  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;
}

Handling Push Events in the Service Worker

// In your service worker
self.addEventListener('push', event => {
  if (event.data) {
    const data = event.data.json();
    
    const options = {
      body: data.body,
      icon: '/images/notification-icon.png',
      badge: '/images/badge-icon.png',
      vibrate: [100, 50, 100],
      data: {
        url: data.url
      },
      actions: [
        {
          action: 'explore',
          title: 'View Details'
        },
        {
          action: 'close',
          title: 'Dismiss'
        }
      ]
    };
    
    event.waitUntil(
      self.registration.showNotification(data.title, options)
    );
  }
});

// Handle notification clicks
self.addEventListener('notificationclick', event => {
  event.notification.close();
  
  if (event.action === 'explore') {
    // Open the URL provided in the notification data
    event.waitUntil(
      clients.openWindow(event.notification.data.url)
    );
  }
});

Workbox: Service Worker Tooling

Workbox is a set of libraries and Node modules that make it easier to cache assets and take full advantage of features used to build Progressive Web Apps.

// Using Workbox in your service worker
importScripts('https://storage.googleapis.com/workbox-cdn/releases/6.1.5/workbox-sw.js');

// Cache the Google Fonts stylesheets
workbox.routing.registerRoute(
  ({url}) => url.origin === 'https://fonts.googleapis.com',
  new workbox.strategies.StaleWhileRevalidate({
    cacheName: 'google-fonts-stylesheets',
  })
);

// Cache the Google Fonts webfont files
workbox.routing.registerRoute(
  ({url}) => url.origin === 'https://fonts.gstatic.com',
  new workbox.strategies.CacheFirst({
    cacheName: 'google-fonts-webfonts',
    plugins: [
      new workbox.cacheableResponse.CacheableResponsePlugin({
        statuses: [0, 200],
      }),
      new workbox.expiration.ExpirationPlugin({
        maxAgeSeconds: 60 * 60 * 24 * 365, // 1 year
        maxEntries: 30,
      }),
    ],
  })
);

// Cache CSS and JavaScript files
workbox.routing.registerRoute(
  ({request}) => request.destination === 'style' || request.destination === 'script',
  new workbox.strategies.StaleWhileRevalidate({
    cacheName: 'static-resources',
  })
);

// Cache images
workbox.routing.registerRoute(
  ({request}) => request.destination === 'image',
  new workbox.strategies.CacheFirst({
    cacheName: 'images',
    plugins: [
      new workbox.expiration.ExpirationPlugin({
        maxEntries: 60,
        maxAgeSeconds: 30 * 24 * 60 * 60, // 30 days
      }),
    ],
  })
);

Service Worker Communication

Service Workers can communicate with the pages they control through the postMessage API.

Sending Messages from the Page to the Service Worker

// From the web page
navigator.serviceWorker.controller.postMessage({
  type: 'CACHE_NEW_ROUTE',
  payload: {
    route: '/new-article',
  }
});

Receiving Messages in the Service Worker

// In the service worker
self.addEventListener('message', event => {
  if (event.data.type === 'CACHE_NEW_ROUTE') {
    const route = event.data.payload.route;
    caches.open(CACHE_NAME)
      .then(cache => cache.add(route))
      .then(() => {
        console.log(`Cached route: ${route}`);
      });
  }
});

Sending Messages from the Service Worker to Pages

// In the service worker
self.clients.matchAll()
  .then(clients => {
    clients.forEach(client => {
      client.postMessage({
        type: 'CACHE_UPDATED',
        payload: {
          updatedUrl: '/updated-content'
        }
      });
    });
  });

Receiving Messages in the Page

// In the web page
navigator.serviceWorker.addEventListener('message', event => {
  if (event.data.type === 'CACHE_UPDATED') {
    console.log(`Content updated: ${event.data.payload.updatedUrl}`);
    // Maybe show a refresh button to the user
  }
});

Debugging Service Workers

Debugging Service Workers can be challenging, but modern browsers provide tools to help:

Chrome DevTools

  • Open DevTools and go to the Application tab
  • Under the "Application" section, click on "Service Workers"
  • Here you can see all registered Service Workers, stop/start them, and force updates
  • Use "Update on reload" and "Bypass for network" options during development

Firefox DevTools

  • Open DevTools and go to the "Application" panel
  • Click on "Service Workers" in the sidebar

Tip: During development, use self.skipWaiting() in the install event and clients.claim() in the activate event to force the new Service Worker to take control immediately.

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

self.addEventListener('activate', event => {
  event.waitUntil(
    Promise.all([
      // Clean up old caches...
      
      self.clients.claim() // Take control of all clients
    ])
  );
});

Best Practices

  • Progressive Enhancement: Implement Service Workers as an enhancement, not a requirement.
  • Versioning: Always version your caches to manage updates properly.
  • Selective Caching: Don't cache everything; be strategic about what you cache.
  • Offline UX: Design a good offline experience with clear indicators of network status.
  • Testing: Test your Service Worker in various network conditions, including offline.
  • Size Limits: Be aware that browsers may have limits on the size of the cache storage.
  • Security: Never cache sensitive data that should require authentication.

Warning: Service Workers are powerful but can cause hard-to-debug issues if implemented incorrectly. Always provide a way for users to force a refresh (e.g., Ctrl+F5) to bypass the Service Worker cache.

Browser Support

Service Workers are supported in all modern browsers:

  • Chrome 40+
  • Firefox 44+
  • Safari 11.1+
  • Edge 17+
  • Opera 27+

Summary

Service Workers are a powerful technology that enables web applications to work offline, load faster, and provide a more app-like experience. They are a fundamental component of Progressive Web Apps and represent a significant advancement in web capabilities.

By implementing Service Workers in your web applications, you can:

  • Provide offline functionality
  • Improve performance through strategic caching
  • Enable background sync for deferred actions
  • Implement push notifications
  • Create a more reliable and resilient user experience