Skip to content

Tips for working with ServiceWorker (extracted from a talk at JSConf.eu)

Notifications You must be signed in to change notification settings

popeindustries/sw-tips

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

6 Commits
 
 

Repository files navigation

Tips for working with ServiceWorker (extracted from a talk at JSConf.eu)

async/await

understand how promises work, but use async/await instead. The ServiceWorker API takes promise use to new extremes, so using async/await can help make things way more legible:

async function install(version, assets) {
  const cache = await caches.open(version);
  return cache.addAll(assets);
}

Although very few browsers support native async/await, it's just syntactic sugar over generators and promises, and every browser that supports ServiceWorker supports async code converted to use generators.

Using Babel with the async-to-generator plugin will add minimal extra overhead, but be aware that this code cannot be minified with Uglify-js, which only supports ES5 input source. Babili is another plugin for Babel that you can use for minification instead.

Don't register the ServiceWorker while the page is loading

Bandwidth and cpu time must be shared while the cache is being filled during the ServiceWorker's installation phase, so wait for the window.onload event (or some other signal) before registering:

if ('serviceWorker' in navigator) {
  window.addEventListener('load', () => {
    navigator.serviceWorker.register('sw.js');
  });
}

All about registration (Jeff Posnick).

Know your dependencies

During the installation phase, passing a promise to event.waitUntil will delay ServiceWorker activation until resolved. However, if rejected, the ServiceWorker will be thrown away and marked redundant.

Since the installation phase is when you want to pre-cache assets, any asset that fails to load will cause a rejection.

In this sense, pre-cached assets should be considered hard dependencies, so beware!

self.addEventListener('install', event => {
  event.waitUntil(install());
});

async function install() {
  const cache = await caches.open('v1');
  return cache.addAll(ASSETS);
}

All about lifecycle (Jake Archibald)

Cache smarter

When upgrading a ServiceWorker, it's common to pre-cache assets in a new, uniquely named cache before deleting old ones during the activation phase:

self.addEventListener('activate', event => {
  event.waitUntil(activate());
});

async function activate() {
  const keys = await caches.keys();
  return Promise.all(
    keys.map(key => {
      if (key !== 'v2') return caches.delete(key);
    })
  );
}

In most cases, this is a good approach, but if you release often, you can avoid wasting storage space and bandwidth by only fetching new assets and recycling the old ones.

Create more than one cache to separate versioned assets from those that won't change:

self.addEventListener('install', event => {
  event.waitUntil(
    Promise.all([
      cacheStatic(ASSETS_STATIC),
      cacheVersioned('2', ASSETS_VERSIONED)
    ])
  );
});

async function cacheStatic(assets) {
  const exists = await caches.has('static');
  if (!exists) {
    const cache = await caches.open('static');
    return cache.addAll(assets);
  }
}

And copy existing versioned assets from the old cache, if they already exist:

async function cacheVersioned(version, assets) {
  const exists = await caches.has(`version-${version}`);
  if (!exists) {
    const requests = assets.map(asset => new Request(asset));
    const preCachedResponses = await Promise.all(
      requests.map(req => caches.match(req))
    );
    const cache = await caches.open(`version-${version}`);
    return Promise.all(
      requests.map((request, idx) => {
        return preCachedResponses[idx]
          ? cache.put(request, preCachedResponses[idx].clone())
          : cache.add(request);
      })
    );
  }
}

Avoid forcing activation for major changes

Forcing activation after an update can break already connected clients if the new ServiceWorker behaves very differently from the old one.

Avoid calling self.skipWaiting() after a major change, and consider prompting the user to trigger a refresh instead:

/* index.html */
navigator.serviceWorker.register('sw.js').then(reg => {
  handleUpgrade(reg);
});

// Handle upgrade to new ServiceWorker
function handleUpgrade(reg) {
  function listenForStateChange() {
    reg.installing.addEventListener('statechange', () => {
      if (this.state === 'installed') triggerReload(reg);
    });
  }
  if (!reg) return;
  if (reg.waiting) return triggerReload(reg);
  if (reg.installing) listenForStateChange();
  reg.addEventListener('updatefound', listenForStateChange);
}

function triggerReload(reg) {
  // Show interactive prompt and trigger skipWaiting
  showPromptSomehow().then(() => {
    reg.waiting.postMessage('skipWaiting');
  });
}

// Reload when new ServiceWorker becomes active
let reloaded;
navigator.serviceWorker.addEventListener('controllerchange', () => {
  if (reloaded) return;
  reloaded = true;
  window.location.reload();
});

/* sw.js */
// Listen for skipWaiting confirmation from client
addEventListener('message', msg => {
  if (msg.data === 'skipWaiting') {
    self.skipWaiting();
  }
});

Use a library for messaging

Sending messages between a ServiceWorker and it's clients can be a little unintuitive if you haven't worked with the postMessage API before. Using a messaging library like Swivel can help:

const swivel = require('swivel');

// Handle messages from ServiceWorker
swivel.on('data', (context, ...data) => {
  // Handle data
});

// Send message to ServiceWorker
swivel.emit('data', ...data);

// Send message to clients
swivel.broadcast('data', ...data);

Never rename the ServiceWorker script file

Once a ServiceWorker has been installed and activated, it will need to be updated. If the html file that registers the ServiceWorker is itself cached, it will be difficult to install a new ServiceWorker with a different name.

Avoid this chicken-and-egg problem by making sure the ServiceWorker script filename is never unique:

// Don't
navigator.serviceWorker.register('sw-v1.js');
// Do
navigator.serviceWorker.register('sw.js');

Update from @ithinkihaveacat: there are certain circumstances where you may want to install a different ServiceWorker under a new name.

Set correct cache headers

If ServiceWorker script filenames are static, and the browser fetches the script from the browser cache before going to the network, you will need to correctly set cache-control headers to prevent the browser from caching outdated versions.

Use no-cache or max-age=0 to always fetch from the network, or a max-age of a few minutes (at most) to benefit from client/edge caching offload (browser, CDN, etc):

# never cache
cache-control: max-age=0
# cache for 2 min
cache-control: max-age=120

As a precaution, to avoid accidentally installing a ServiceWorker for days/weeks/months, caches will be bypassed if the script is older than 24 hours, regardless of what you set.

Cache invalidation is always tricky, so in the future, browsers will use "cache busting" by default to ensure that ServiceWorker script files are always kept up-to-date.

More on updating (Jeff Posnick)

And caching best practices (Jake Archibald)

Invalidate your ServiceWorker when updated

The ServiceWorker will be re-installed if it is byte different from the previous version. A simple setup is to treat the ServiceWorker file as a boot loader by using importScripts with versioned files:

// sw.js
self.importScripts('vendor-sw-v1', 'index-sw-v1');
// ...that's all you need!

Add a feature flag/kill switch

In the event of disaster, having an easy way to disable existing ServiceWorkers can be a lifesaver. Add a feature flag to control unregistration:

if ('serviceWorker' in navigator) {
  window.addEventListener('load', () => {
    if (features.serviceWorker) {
      navigator.serviceWorker.register('sw.js');
    } else {
      navigator.serviceWorker.getRegistrations().then(registrations => {
        registrations.forEach(registration => {
          registration.unregister();
        });
      });
    }
  });
}

...keep a suicide ServiceWorker handy for quick deploy:

self.addEventListener('install', event => self.skipWaiting());

self.addEventListener('activate', event => {
  event.waitUntil(burnDownTheHouse());
});

async function burnDownTheHouse() {
  // Unregister
  self.registration.unregister();
  // Delete all caches
  const keys = await self.caches.keys();
  await Promise.all(keys.map(key => self.caches.delete(key));
  // Force refresh all windows
  const clients = await self.clients.matchAll({ type: 'window' });
  clients.forEach(client => client.navigate(client.url))
}

// No 'fetch' handler

...or have the ServiceWorker phone home to check it's version, then force an update if outdated:

versionCheck();

async function versionCheck() {
  const response = await fetch(SW_VERSION_URL);
  const version = await response.text();
  if (version !== VERSION) {
    self.registration.update();
  }
}

More on kill switches (Jeff Posnick)

Don't cache bad responses

Always check the ok property of the response object returned from fetch() before you add it to your cache. HTTP error response codes (4xx, 5xx) won't cause the promise to reject:

async function onFetch(event) {
  try {
    const response = await fetch(event.request);
    if (response.ok) {
      // Cache it
    } else {
      throw Error(`error fetching with ${response.status}`);
    }
  } catch (error) {
    // Handle response error
  }
}

Don't store global state

Storing global state in a ServiceWorker is bad. Code outside of event handlers is run each time a ServiceWorker is started, but they're stopped and started many times over their lifetime in order to save battery and other resources, and that global state will be destroyed at unexpected times:

// Declared on each start
let db;

self.addEventListener('install', event => {
  // Assigned only when installed on first start
  db = openDB();
});
self.addEventListener('fetch', event => {
  event.respondWith(
    // Probably doesn't exist
    db.readStuff().then(/*...*/)
  );
});

More about the risks here (Jeff Posnick)

Guard against missing APIs

A number of API methods were added in later browser versions, so it's wise to test whether they exist before calling them:

if (self.skipWaiting) {
  self.skipWaiting();
}

The following methods were added in later versions of Chrome, after ServiceWorker was launched:

// Chrome 42
self.skipWaiting();
clients.claim();

// Chrome 46
cache.add();
cache.addAll();

// Chrome 47
cache.matchAll();

Test your ServiceWorker

Because of the installation lifecycle and the special environment they run in, ServiceWorkers are very difficult to test. As always, running tests in real browsers, with real code, will give the most realistic results.

Unfortunately, there aren't yet any good tools to help with browser tests, but the methodology is well laid out in this article by Matt Gaunt of Google.

Automating browser tests comes with it's own set of challenges, so it's often desirable to test as much as possible with lightweight unit tests. Fortunately, there are some tools available to easily mock and test the ServiceWorker environment.

As part of their service-workers toolchain, Pinterest has developed helper functions and a mock you can use to make the Node.js global scope look like a ServiceWorker:

const makeServiceWorkerEnv = require('service-worker-mock');

describe('ServiceWorker', () => {
  beforeEach(() => {
    Object.assign(global, makeServiceWorkerEnv());
    jest.resetModules();
  });
  it('should add listeners', () => {
    require('sw.js');
    expect(self.listeners['install']).toBeDefined();
    expect(self.listeners['activate']).toBeDefined();
    expect(self.listeners['fetch']).toBeDefined();
  });
});

I also released a project for testing ServiceWorkers in Node.js called sw-test-env. It's a little more thorough mock of the ServiceWorker specification, and allows you to run ServiceWorker code in an isolated, sandboxed context:

const { connect, destroy } = require('sw-test-env');
let sw;

describe('ServiceWorker', () => {
  beforeEach(() => {
    sw = connect();
  });
  afterEach(destroy);
  it('should add listeners', async () => {
    await sw.register('sw.js');
    expect(sw.scope._listeners['install']).toBeDefined();
    expect(sw.scope._listeners['activate']).toBeDefined();
    expect(sw.scope._listeners['fetch']).toBeDefined();
  });
});

With it you can:

  • inspect the properties of the ServiceWorker scope (clients, caches, registration, etc)
  • manually trigger events (install, activate, fetch, etc)
  • postMessage between clients and ServiceWorker instances
  • use importScripts()
  • fetch() real (or mocked) data
  • use indexedDB storage
  • require() modules without a build step
it('should recycle assets on upgrade', async () => {
  // Load the ServiceWorker file
  await sw.register('./fixtures/cache-smarter.js');
  // Create and populate an old version of the cache
  const cache = await sw.scope.caches.open('version-1');
  await cache.put(new Request('bar.js'), new Response('bar'));
  // Trigger the "installation" phase
  await sw.trigger('install');
  // Read from the cache and verify
  const bar = await sw.scope.caches.match(new Request('bar.js'));
  const body = await bar.text();

  expect(bar.ok).to.equal(true);
  expect(bar.status).to.equal(200);
  expect(body).to.equal('bar');
});

Use a ServiceWorker generator tool

If you don't want to get your hands dirty with the details, you can use one of several ServiceWorker generator tools and libraries:

About

Tips for working with ServiceWorker (extracted from a talk at JSConf.eu)

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published