Mazzarolo MatteoMazzarolo Matteo

Random notes around service workers development and testing

By Mazzarolo Matteo


Hey!
Here are a few random tips and tricks I learned through the years around service workers development and testing.

Helpful service worker resources

Reloading a service worker

Reloading a page won't update/remove the previous version of its service worker.
If you're using Chrome, to ensure you're using the latest version of your service worker, tick the "Update on reload" toggle in the "Application" ⭢ "Service Workers" section of the Chrome DevTools.

Simulate a network condition

To simulate a network condition (e.g., offline, 3g, etc...) in a service worker in Chrome, untick the "Update on reload" toggle.

I'm still not sure why/if this is really needed. In a few cases, when the toggle was enabled and I was simulating an offline mode, the service worker was still going through the network 🤷‍♂️

Service workers don't intercept other service worker registration events

Service workers can't intercept network requests made by the navigator.serviceWorker.register("sw.js") API. This means you don't have to worry about service workers caching themselves.

Testing exceeded quota errors on service workers

Each browser has a different limit on the cache storage available for a service worker. On desktop browsers, it will be based on the percentage of available space on disk, so testing (in development) what happens when the cache quota exceeds can be tricky.

A possible workaround is:

  1. Use Chrome in incognito mode, which has a hard limit of 100MB.
  2. Manually cache huge assets (e.g., 8k resolution images).
  3. Watch the quota exceed.

Service workers don't run in Firefox private mode

In Firefox, Service Worker APIs are hidden and cannot be used when the user is in private browsing mode.

Service workers registration fails on Firefox if cookies are disabled or set to be cleared on quit

Registering a Service Worker in Firefox throws a "The operation is insecure." exception if cookies are disabled or set to be cleared on quit.

Service workers and Chrome incognito mode

You may find it helpful to test your service worker in an Incognito window so you can close and reopen it, knowing that the previous service worker won't affect the new window. Any registrations and caches created within an incognito window will be cleared once that window is closed.

That said, please keep in mind that single incognito tabs and windows are not sandboxed. So, if you have two different incognito windows open simultaneously, they'll share cookies, storage data, and service workers.

Force-reload

If you force-reload the page (shift-reload), it bypasses the service worker entirely. It'll be uncontrolled. This feature is in the spec, so it should work in any browser that supports service workers.

Serving a service worker on immutable paths

It's against best practices to use an immutable path for service workers (e.g., if the service worker has a hash in its name). However, if you're not using the service worker to cache the HTML that serves it, you shouldn't worry about it.

How often is a service worker updated?

A service worker will update every 24 hours or when all pages/instances are closed.

Keep a no-op service worker handy

If you don't want to leave your users running a buggy service worker code while you take the time to work out a solution, it's a good idea to keep a simple, no-op service-worker.js handy, like the following:

// A simple, no-op service worker that takes immediate control.
 
self.addEventListener("install", () => {
  // Skip over the "waiting" lifecycle state, to ensure that our
  // new service worker is activated immediately, even if there's
  // another tab open controlled by our older service worker code.
  self.skipWaiting();
});
 
self.addEventListener("activate", () => {
  // Optional: Get a list of all the current open windows/tabs under
  // our service worker's control, and force them to reload.
  // This can "unbreak" any open windows/tabs as soon as the new
  // service worker activates, rather than users having to manually reload.
  self.clients.matchAll({ type: "window" }).then((windowClients) => {
    windowClients.forEach((windowClient) => {
      windowClient.navigate(windowClient.url);
    });
  });
});
// A simple, no-op service worker that takes immediate control.
 
self.addEventListener("install", () => {
  // Skip over the "waiting" lifecycle state, to ensure that our
  // new service worker is activated immediately, even if there's
  // another tab open controlled by our older service worker code.
  self.skipWaiting();
});
 
self.addEventListener("activate", () => {
  // Optional: Get a list of all the current open windows/tabs under
  // our service worker's control, and force them to reload.
  // This can "unbreak" any open windows/tabs as soon as the new
  // service worker activates, rather than users having to manually reload.
  self.clients.matchAll({ type: "window" }).then((windowClients) => {
    windowClients.forEach((windowClient) => {
      windowClient.navigate(windowClient.url);
    });
  });
});

Related post on StackOverflow here.

Get the "client" sender instance from a postMessage

In a service worker message listener, event.source is the client instance of the sender.

self.addEventListener("message", (event) => {
  const senderClient = event.source;
  // This allows you (for example) to easily send back a message to the client.
  senderClient.postMessage("👋");
});
self.addEventListener("message", (event) => {
  const senderClient = event.source;
  // This allows you (for example) to easily send back a message to the client.
  senderClient.postMessage("👋");
});

Clean old cache

It's a good idea to version the service worker cache by hardcoding a version number in the service worker. You can then bump it whenever you want to clear the old cache (e.g., because you added a new caching logic).

Here's an example of how you can clean your cache (using Workbox):

import { cacheNames } from 'workbox-core';
import { registerRoute } from 'workbox-routing';
import { CacheFirst } from 'workbox-strategies';
 
const SERVICE_WORKER_VERSION = "v1"; // Bump it manually when needed.
 
const caches = [
  {name: 'images', matcher: new RegExp('/static/images')},
  {name: 'fonts', matcher: new RegExp('/static/fonts')}
];
 
caches.forEach({name, matcher} => {
  registerRoute(
    matcher,
    new CacheFirst({ cacheName: `${name}-${SERVICE_WORKER_VERSION}` })
  )
});
 
self.addEventListener('activate', (event) => {
  async function cleanupOldCaches() {
    // Keep caches supported by the current version of the service worker
    const cachesToKeep = caches.map(({ name }) => `${name}-${SERVICE_WORKER_VERSION}`);
    // Also keep caches that Workbox uses internally
    cachesToKeep.push(cacheNames.precache, cacheNames.runtime, cacheNames.googleAnalytics);
    const allCaches = await caches.keys();
    const cachesToCleanup = allCaches.filter((cache) => !cachesToKeep.includes(cache));
    for (const cacheToCleanup of cachesToCleanup) {
      // Delete the cache
      await caches.delete(cacheToCleanup);
      // Delete the IDB cache expiration informations used by Workbox.
      // See this comment on why we need to set "maxEntries" to 1:
      // https://github.com/GoogleChrome/workbox/issues/2234
      const cacheExpiration = new CacheExpiration(cacheToCleanup, { maxEntries: 1 });
      cacheExpiration.delete();
    }
  }
  // Keep the service worker alive until all caches are deleted.
  event.waitUntil(cleanupOldCaches());
});
import { cacheNames } from 'workbox-core';
import { registerRoute } from 'workbox-routing';
import { CacheFirst } from 'workbox-strategies';
 
const SERVICE_WORKER_VERSION = "v1"; // Bump it manually when needed.
 
const caches = [
  {name: 'images', matcher: new RegExp('/static/images')},
  {name: 'fonts', matcher: new RegExp('/static/fonts')}
];
 
caches.forEach({name, matcher} => {
  registerRoute(
    matcher,
    new CacheFirst({ cacheName: `${name}-${SERVICE_WORKER_VERSION}` })
  )
});
 
self.addEventListener('activate', (event) => {
  async function cleanupOldCaches() {
    // Keep caches supported by the current version of the service worker
    const cachesToKeep = caches.map(({ name }) => `${name}-${SERVICE_WORKER_VERSION}`);
    // Also keep caches that Workbox uses internally
    cachesToKeep.push(cacheNames.precache, cacheNames.runtime, cacheNames.googleAnalytics);
    const allCaches = await caches.keys();
    const cachesToCleanup = allCaches.filter((cache) => !cachesToKeep.includes(cache));
    for (const cacheToCleanup of cachesToCleanup) {
      // Delete the cache
      await caches.delete(cacheToCleanup);
      // Delete the IDB cache expiration informations used by Workbox.
      // See this comment on why we need to set "maxEntries" to 1:
      // https://github.com/GoogleChrome/workbox/issues/2234
      const cacheExpiration = new CacheExpiration(cacheToCleanup, { maxEntries: 1 });
      cacheExpiration.delete();
    }
  }
  // Keep the service worker alive until all caches are deleted.
  event.waitUntil(cleanupOldCaches());
});

Related discussion in the Workbox repo here.