How service workers change the caching story

With Service Workers, you gain direct control over caching and request handling, which means you can design custom patterns for offline behavior. In practice you'll likely combine several approaches depending on the URL and the context, so it helps to understand each strategy on its own first. A working demo of these patterns is available in Trained-to-thrill.

Caching at install time

Service workers let you handle requests independently from caching. The install event is your first opportunity to prepare resources, and it comes in two flavors.

Dependencies that must be ready

On install, as a dependency.

Because previous versions of your service worker keep serving pages during install, you can safely prepare anything the new version will need without disrupting existing users. This is the right place for CSS, images, fonts, JS, templates, and anything else that's static to that version of your site.

self.addEventListener('install', function (event) {
  event.waitUntil(
    caches.open('mysite-static-v3').then(function (cache) {
      return cache.addAll([
        '/css/whatever-v3.css',
        '/css/imgs/sprites-v6.png',
        '/css/fonts/whatever-v8.woff',
        '/js/all-min-v4.js',
        // etc.
      ]);
    }),
  );
});

event.waitUntil takes a promise to define the length and success of the install. If the promise rejects the installation fails and the service worker is abandoned, leaving an older running version intact. Since caches.open() and cache.addAll() return promises, a failed fetch of any resource causes cache.addAll() to reject. The trained-to-thrill demo uses this to cache static assets.

Non-blocking preloads

On install, not as a dependency.

This similar approach won't delay install completion, and a caching failure won't cause installation to fail. It suits bigger resources that aren't needed immediately, such as assets for later levels of a game.

self.addEventListener('install', function (event) {
  event.waitUntil(
    caches.open('mygame-core-v1').then(function (cache) {
      cache
        .addAll
        // levels 11-20
        ();
      return cache
        .addAll
        // core assets and levels 1-10
        ();
    }),
  );
});

The code above doesn't pass the cache.addAll promise for levels 11–20 back to event.waitUntil, so the game remains available offline even if that caching fails. You'll need to handle the absence of those levels and retry caching if they're missing.

One caveat: the service worker may be killed while levels 11–20 are downloading since it has finished handling events, leaving them uncached. The Web Periodic Background Synchronization API addresses cases like this and larger downloads such as movies.

While activating

On activate.

An activate event fires once a new service worker has installed and the previous version is no longer in use. Since the old version is out of the way, this is the time for schema migrations in IndexedDB and removing unused caches.

self.addEventListener('activate', function (event) {
  event.waitUntil(
    caches.keys().then(function (cacheNames) {
      return Promise.all(
        cacheNames
          .filter(function (cacheName) {
            // Return true if you want to remove this cache,
            // but remember that caches are shared across
            // the whole origin
          })
          .map(function (cacheName) {
            return caches.delete(cacheName);
          }),
      );
    }),
  );
});

Events such as fetch are queued during activation, so a long-running activation blocks page loads. Keep activation work minimal and reserve it for things you couldn't do while the previous version was active. Trained-to-thrill uses this moment to remove old caches.

After user interaction

On user interaction.

When your whole site can't go offline, you can let users select what they'd like available. This pattern works for save-offline buttons on articles, videos, or photo galleries. When clicked, fetch the content and add it to the cache.

document.querySelector('.cache-article').addEventListener('click', function (event) {
  event.preventDefault();

  var id = this.dataset.articleId;
  caches.open('mysite-article-' + id).then(function (cache) {
    fetch('/get-article-urls?id=' + id)
      .then(function (response) {
        // /get-article-urls returns a JSON-encoded array of
        // resource URLs that a given article depends on
        return response.json();
      })
      .then(function (urls) {
        cache.addAll(urls);
      });
  });
});

The Cache API is accessible from both pages and service workers, so you can add to the cache directly from page JavaScript.

On demand and revalidation

Two patterns cover content that changes regularly. The first is catching responses at request time; the second is a hybrid approach borrowed from HTTP.

On network response

On network response.

If a request misses the cache, fetch it from the network, serve it to the page, and add it to the cache in a single pass. This fits frequently-updating content like an inbox or article bodies, and also works for non-essential items like avatars with caution.

self.addEventListener('fetch', function (event) {
  event.respondWith(
    caches.open('mysite-dynamic').then(function (cache) {
      return cache.match(event.request).then(function (response) {
        return (
          response ||
          fetch(event.request).then(function (response) {
            cache.put(event.request, response.clone());
            return response;
          })
        );
      });
    }),
  );
});

A response or request body can only be read once, so the code uses .clone() to create separately-readable copies. When applying this across many URLs — avatars, for instance —watch your origin's storage usage. The browser may discard your data under storage pressure, so remove cached items you no longer need. Trained-to-thrill applies this to cache Flickr images.

Stale-while-revalidate

Stale-while-revalidate.

For frequently-updating resources where the absolute latest version isn't critical, serve the cached copy immediately and fetch an update for next time. Avatars fit this pattern well.

self.addEventListener('fetch', function (event) {
  event.respondWith(
    caches.open('mysite-dynamic').then(function (cache) {
      return cache.match(event.request).then(function (response) {
        var fetchPromise = fetch(event.request).then(function (networkResponse) {
          cache.put(event.request, networkResponse.clone());
          return networkResponse;
        });
        return response || fetchPromise;
      });
    }),
  );
});

This mirrors HTTP's stale-while-revalidate strategy.

Push messages and background sync

Two built-on-top service worker features handle remote and scheduled updates. Both can wake the service worker while the user has no open tab. Permission is requested from a page with a user prompt.

The Push API responds to messages from the OS's messaging service. It suits content tied to notifications — chat, breaking news, email — and infrequent changes that need immediate sync, such as a to-do list or calendar alteration. The usual outcome is a notification that opens and focuses a page when tapped, so pre-updating caches is critical: the user is online when they receive the push but may not be online when they interact with it.

This code updates caches before showing a notification:

self.addEventListener('push', function (event) {
  if (event.data.text() == 'new-email') {
    event.waitUntil(
      caches
        .open('mysite-dynamic')
        .then(function (cache) {
          return fetch('/inbox.json').then(function (response) {
            cache.put('/inbox.json', response.clone());
            return response.json();
          });
        })
        .then(function (emails) {
          registration.showNotification('New email', {
            body: 'From ' + emails[0].from.name,
            tag: 'new-email',
          });
        }),
    );
  }
});

self.addEventListener('notificationclick', function (event) {
  if (event.notification.tag == 'new-email') {
    // Assume that all of the resources needed to render
    // /inbox/ have previously been cached, e.g. as part
    // of the install handler.
    new WindowClient('/inbox/');
  }
});

Background sync requests data synchronization as a one-off, or on a heuristic interval. It suits non-urgent updates that occur too frequently for push messages, such as social timelines and news articles.

self.addEventListener('sync', function (event) {
  if (event.id == 'update-leaderboard') {
    event.waitUntil(
      caches.open('mygame-dynamic').then(function (cache) {
        return cache.add('/leaderboard.json');
      }),
    );
  }
});

Managing and protecting cached data

Your origin gets an unspecified amount of free space shared across (local) Storage, IndexedDB, File System Access, and Caches. The actual quota varies by device and storage conditions. Check your available space with:

if (navigator.storage && navigator.storage.estimate) {
  const quota = await navigator.storage.estimate();
  // quota.usage -> Number of bytes used.
  // quota.quota -> Maximum number of bytes available.
  const percentageUsed = (quota.usage / quota.quota) * 100;
  console.log(`You've used ${percentageUsed}% of the available storage.`);
  const remaining = quota.quota - quota.usage;
  console.log(`You can write up to ${remaining} more bytes.`);
}

Browser storage can be evicted under device storage pressure, without regard to whether that's a movie you want to keep forever or a game you never play. Use the StorageManager interface to mark an origin as durable and reduce this risk:

// From a page:
navigator.storage.persist()
.then(function(persisted) {
  if (persisted) {
    // Hurrah, your data is here to stay!
  } else {
   // So sad, your data may get chucked. Sorry.
});

The user must grant permission via the Permissions API. Bringing users into this flow matters because they are the ultimate judge of what gets kept when space is short. This assumes operating systems treat a "durable" origin like a platform-specific app in storage estimates, rather than lumping all browser data into a single browser-level entry.

Choosing a request-handling strategy

Caching alone is only half the story. The service worker only consults that cache when you program it to. Selecting the right strategy for a given request—or a combination of them—determines the offline behavior your users will actually experience.

Cache first, network as backup

The most common approach for an offline-first application is to serve from the cache when the request is a match, and fall back to the network otherwise. This effectively gives you cache-only behavior for known assets and network-only behavior for everything else, including non-GET requests, which cannot be cached.

Cache, falling back to network.

This is the pattern to apply to the bulk of your traffic. The other strategies covered here are tailored to the exceptions.

Network first, cache as backup

For resources that change frequently but need to keep working offline—articles, avatars, leaderboards—start with the network. Online users get the freshest data; if the request succeeds, update the cached copy. Offline users get the last cached version.

The trade-off is speed. On a slow or intermittent connection, the user waits for the network to fail before receiving content that was already sitting on their device. That delay can be severely annoying, which makes this strategy more of a quick fix than a solid baseline.

Network falling back to cache.

Cache then network

A better option for frequently updated content is to fire two requests: one to the cache and one to the network. Display the cached data immediately, then update the page when the network response arrives. Twitter handles this by inserting new content above the old and adjusting the scroll position, so the user is not disturbed.

Replacing a large block of content the user is reading can be jarring; consider whether a full swap is acceptable. For a leaderboard it might be, for an article it usually is not.

The page makes the initial request to the cache:

var networkDataReceived = false;

startSpinner();

// fetch fresh data
var networkUpdate = fetch('/data.json')
  .then(function (response) {
    return response.json();
  })
  .then(function (data) {
    networkDataReceived = true;
    updatePage(data);
  });

// fetch cached data
caches
  .match('/data.json')
  .then(function (response) {
    if (!response) throw Error('No data');
    return response.json();
  })
  .then(function (data) {
    // don't overwrite newer network data
    if (!networkDataReceived) {
      updatePage(data);
    }
  })
  .catch(function () {
    // we didn't get cached data, the network is our last hope:
    return networkUpdate;
  })
  .catch(showErrorMessage)
  .then(stopSpinner);

In the service worker, always go to the network and update the cache along the way:

self.addEventListener('fetch', function (event) {
  event.respondWith(
    caches.open('mysite-dynamic').then(function (cache) {
      return fetch(event.request).then(function (response) {
        cache.put(event.request, response.clone());
        return response;
      });
    }),
  );
});

Fetching with the browser's fetch API and distinguishing between the cache and network requests can require some workarounds. For example, trained-to-thrill uses XHR and an Accept header hint to tell the service worker which request is which.

Cache-only and network-only shortcuts

Purely cache-only responses work well for versioned static assets you stored during the install event, and network-only suits analytics pings or non-GET requests. In practice, both are special cases of the cache-falling-back-to-network pattern, so you rarely need to write them out explicitly.

Cache and network race.

The cache-and-network race is an extra option for small assets when disk I/O is the bottleneck—older hard drives and virus scanners combined with a fast connection can make fetching from the network cheaper than reading the cache. Be mindful of the data cost, though, when the content is already on the device.

Generic fallback

When neither the cache nor the network can produce what was requested, serve a generic substitute: a "while offline" page, a placeholder avatar, a failed POST response. The fallback itself should be an install-time dependency so it is guaranteed to exist. For example, an outgoing email that fails can be stored in an IndexedDB outbox while the service worker tells the page the send did not go through but the message was kept.

Generic fallback.

Service worker-side templating is another variation for content that cannot be cached as a server response, such as pages that embed sign-in state. Serve a template plus JSON data instead of a rendered page.

Combining patterns

There is no rule limiting you to one strategy across your entire site. Inspect the request URL and branch accordingly. The trained-to-thrill sample app demonstrates this mix:

  • Cache on install, for static UI and behavior.
  • Cache on network response, for fetched images and data.
  • Fetch from cache, falling back to network, for most requests.
  • Fetch from cache, then network, for search results.
self.addEventListener('fetch', function (event) {
  // Parse the URL:
  var requestURL = new URL(event.request.url);

  // Handle requests to a particular host specifically
  if (requestURL.hostname == 'api.example.com') {
    event.respondWith(/* some combination of patterns */);
    return;
  }
  // Routing for local URLs
  if (requestURL.origin == location.origin) {
    // Handle article URLs
    if (/^\/article\//.test(requestURL.pathname)) {
      event.respondWith(/* some other combination of patterns */);
      return;
    }
    if (/\.webp$/.test(requestURL.pathname)) {
      event.respondWith(/* some other combination of patterns */);
      return;
    }
    if (request.method == 'POST') {
      event.respondWith(/* some other combination of patterns */);
      return;
    }
    if (/cheese/.test(requestURL.pathname)) {
      event.respondWith(
        new Response('Flagrant cheese error', {
          status: 512,
        }),
      );
      return;
    }
  }

  // A sensible default pattern
  event.respondWith(
    caches.match(event.request).then(function (response) {
      return response || fetch(event.request);
    }),
  );
});

Further material can be found in the documentation on the Cache Storage API and on JavaScript promises.