58

I've been reading through the html5rocks Introduction to service worker article and have created a basic service worker that caches the page, JS and CSS which works as expected:

var CACHE_NAME = 'my-site-cache-v1';
var urlsToCache = [
  '/'
];

// Set the callback for the install step
self.addEventListener('install', function (event) {
  // Perform install steps
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then(function(cache) {
        console.log('Opened cache');
        return cache.addAll(urlsToCache);
      })
  );
});

self.addEventListener('fetch', function (event) {
  event.respondWith(
    caches.match(event.request)
      .then(function(response) {
        // Cache hit - return response
        if (response) {
          return response;
        }

        // IMPORTANT: Clone the request. A request is a stream and
        // can only be consumed once. Since we are consuming this
        // once by cache and once by the browser for fetch, we need
        // to clone the response
        var fetchRequest = event.request.clone();

        return fetch(fetchRequest).then(
          function(response) {
            // Check if we received a valid response
            if(!response || response.status !== 200 || response.type !== 'basic') {
              return response;
            }

            // IMPORTANT: Clone the response. A response is a stream
            // and because we want the browser to consume the response
            // as well as the cache consuming the response, we need
            // to clone it so we have 2 stream.
            var responseToCache = response.clone();

            caches.open(CACHE_NAME)
              .then(function(cache) {
                cache.put(event.request, responseToCache);
              });

            return response;
          }
        );
      })
    );
});

When I make a change to the CSS, this change is not being picked up as the service worker is correctly returning the CSS from the cache.

Where I'm stuck is if I were to change the HTML, JS or CSS, how would I make sure that the service-worker loads the newer version from the server if it can rather than from the cache? I've tried using version stamps on the CSS import but that didn't seem to work.

Ben Thomas
  • 3,180
  • 2
  • 20
  • 38

5 Answers5

58

One option is just to use a the service worker's cache as a fallback, and always attempt to go network-first via a fetch(). You lose some performance gains that a cache-first strategy offers, though.

An alternative approach would be to use sw-precache to generate your service worker script as part of your site's build process.

The service worker that it generates will use a hash of the file's contents to detect changes, and automatically update the cache when a new version is deployed. It will also use a cache-busting URL query parameter to ensure that you don't accidentally populate your service worker cache with an out-of-date version from the HTTP cache.

In practice, you'll end up with a service worker that uses a performance-friendly cache-first strategy, but the cache will get updated "in the background" after the page loads so that the next time it's visited, everything is fresh. If you want, it's possible to display a message to the user letting them know that there's updated content available and prompting them to reload.

Jeff Posnick
  • 53,580
  • 14
  • 141
  • 167
  • Thanks for the answer. Having read that blog article by Jake I can see that there are several patterns that you can use in different cases. I'll decide what I really want to do and try implementing using one of those patterns. – Ben Thomas Oct 22 '15 at 08:46
  • I'm using sw-precache as you mentioned, but my updated content is not being replaced in cache, so it's getting an old version if I refresh with ctrl+r but the new version if I refresh with ctrl+shift+r. A reason for that can be that my service worker is not being updated. https://developers.google.com/web/fundamentals/getting-started/primers/service-workers#update-a-service-worker states that it's updated if changed one byte, but what if my new service worker didn't change a byte? (as it happens if just one hash has changed). How to force the service work to update? – Jp_ Jan 27 '17 at 21:01
  • 4
    See http://stackoverflow.com/questions/38843970/service-worker-javascript-update-frequency-every-24-hours/38854905#38854905 and confirm that your updated `service-worker.js` file is being fetched from the network, and not from the browser cache. Any changes to even a single file's hash within the generated `service-worker.js` should be enough to trigger the update flow. – Jeff Posnick Jan 27 '17 at 21:39
  • If you're not using Node, and cannot use sw-precache/Workbox, I wrote a detailed post on using a similar approach (using a server running in Python in my case): https://almarklein.org/pwa.html – Almar Mar 10 '21 at 13:56
  • I deploy using git to gh pages, so I establish the list of files to update with a `git diff --diff-filter=ACRM HEAD $lastRemoteCommit -- docs` where lastRemoteCommit is obtained using `git ls-files | grep refs/heads/main | awk '{ print $1 }'`, writing this from memory but u get the spirit. Still troubleshooting some other issues though :) – djfm Jul 30 '21 at 04:20
23

One way of invalidating the cache would be to bump version of the CACHE_NAME whenever you change anything in the cached files. Since that change would change the service-worker.js browser would load a newer version and you'd have a chance to delete the old caches and create new ones. And you can delete the old cache in the activate handler. This is the strategy described in prefetch sample. If you already using some kind of version stamps on CSS files make sure that they find their way into service worker script.

That of course does not change the fact that cache headers on CSS file need to be set correctly. Otherwise service worker will just load the file that is already cached in browser cache.

pirxpilot
  • 1,597
  • 14
  • 17
  • Thanks for the answer. It makes sense that when the new service-worker is activated it will clear the cache and reload the assets from the network. The reason for mentioning version stamps is that I thought that might be a potential solution. – Ben Thomas Oct 22 '15 at 08:44
  • Wouldn't this approach re-download all the resources belonging to the cache rather than downloading just the updated file? Using same cache name but updating the file name with a hash overcomes this issue. – Punit S Jun 09 '18 at 08:02
6

A browser caching issue

The main problem here is that when your new service worker is installing, he fetches requests that are handled by the previous service worker and it's very likely that he's getting resources from cache because this is your caching strategy. Then even though you're updating your service worker with new code, a new cache name, calling self.skipWaiting(), he's still putting in cache the old resources in cache!

This is how I fully update a Service Worker

One thing to know is that a service worker will trigger the install event each time your code script changes so you don't need to use version stamps or anything else, just keeping the same file name is okay and even recommended. There are other ways the browser will consider your service worker updated.

1. Rewrite your install event handler:

I don't use cache.addAll because it is broken. Indeed if one and only one of your resource to cache cannot be fetched, the whole installation will fail and not even one single file will be added to the cache. Now imagine your list of files to cache is automatically generated from a bucket (it's my case) and your bucket is updated and one file is removed, then your PWA will fail installing and it should not.

sw.js

self.addEventListener('install', (event) => {
  // prevents the waiting, meaning the service worker activates
  // as soon as it's finished installing
  // NOTE: don't use this if you don't want your sw to control pages
  // that were loaded with an older version
  self.skipWaiting();

  event.waitUntil((async () => {
    try {
      // self.cacheName and self.contentToCache are imported via a script
      const cache = await caches.open(self.cacheName);
      const total = self.contentToCache.length;
      let installed = 0;

      await Promise.all(self.contentToCache.map(async (url) => {
        let controller;

        try {
          controller = new AbortController();
          const { signal } = controller;
          // the cache option set to reload will force the browser to
          // request any of these resources via the network,
          // which avoids caching older files again
          const req = new Request(url, { cache: 'reload' });
          const res = await fetch(req, { signal });

          if (res && res.status === 200) {
            await cache.put(req, res.clone());
            installed += 1;
          } else {
            console.info(`unable to fetch ${url} (${res.status})`);
          }
        } catch (e) {
          console.info(`unable to fetch ${url}, ${e.message}`);
          // abort request in any case
          controller.abort();
        }
      }));

      if (installed === total) {
        console.info(`application successfully installed (${installed}/${total} files added in cache)`);
      } else {
        console.info(`application partially installed (${installed}/${total} files added in cache)`);
      }
    } catch (e) {
      console.error(`unable to install application, ${e.message}`);
    }
  })());
});

2. Clean the old cache when the (new) service worker is activated:

sw.js

// remove old cache if any
self.addEventListener('activate', (event) => {
  event.waitUntil((async () => {
    const cacheNames = await caches.keys();

    await Promise.all(cacheNames.map(async (cacheName) => {
      if (self.cacheName !== cacheName) {
        await caches.delete(cacheName);
      }
    }));
  })());
});

3. I update the cache name every time I have updated my assets:

sw.js

// this imported script has the newly generated cache name (self.cacheName)
// and a list of all the files on my bucket I want to be cached (self.contentToCache),
// and is automatically generated in Gitlab based on the tag version
self.importScripts('cache.js');

// the install event will be triggered if there's any update,
// a new cache will be created (see 1.) and the old one deleted (see 2.)

4. Handle Expires and Cache-Control response headers in cache

I use these headers in the service worker's fetch event handler to catch whether it should request the resource via the network when a resource expired/should be refreshed.

Basic example:

// ...

try {
  const cachedResponse = await caches.match(event.request);

  if (exists(cachedResponse)) {
    const expiredDate = new Date(cachedResponse.headers.get('Expires'));

    if (expiredDate.toString() !== 'Invalid Date' && new Date() <= expiredDate) {
      return cachedResponse.clone();
    }
  }

  // expired or not in cache, request via network...
} catch (e) {
  // do something...
}
// ...
adrienv1520
  • 131
  • 1
  • 3
  • Why not just use always the same cache name say `const cacheName = 'my-pwa.io'` and within install event handler do a `caches.delete(cacheName)`? That should avoid populating the new cache with old data no? – djfm Jul 30 '21 at 04:00
1

Simplest for me:

const cacheName = 'my-app-v1';

self.addEventListener('activate', async (event) => {

    const existingCaches = await caches.keys();
    const invalidCaches = existingCaches.filter(c => c !== cacheName);
    await Promise.all(invalidCaches.map(ic => caches.delete(ic)));

    // do whatever else you need to...

});

If you have more than once cache you can just modify the code to be selective.

0

In my main page i use some PHP to fetch data from mySQL.

To have the php data fresh at all times when you have internet i use the date in milliseconds as version for my service worker.

In this case, the cashed pages will always update when you have internet and reload the page.

//SET VERSION
const version = Date.now();
const staticCacheName = version + 'staticfiles';

//INSTALL
self.addEventListener('install', function(e) {
    e.waitUntil(
        caches.open(staticCacheName).then(function(cache) {
            return cache.addAll([
Björn C
  • 3,860
  • 10
  • 46
  • 85
  • 4
    Won't this just not cache the files because the version is changing every second? – user May 21 '21 at 01:50