# Step 3: Precaching static assets for a basic offline mode

We saw in the previous step two methods of the Service Worker life cycle: install andactivate. In this part, we will continue our exploration of PWA by caching static files.

# Preamble: Promises and async/await

Service worker APIs rely heavily on promises. Let's take a quick look on how they work.

TIP

You can try the code in this section using node or an online editor such as https://repl.it (opens new window)

Promises provide an interface to handle series of asynchronous function calls more conveniently. They improve code readability compared to callbacks passed as arguments, for example. The ES2015 JavaScript specification allows to create promises using this constructor:

const promise = new Promise((resolve, reject) => {
  // async function execution
  // resolve is called on success
  // reject is called on failure
});

Here is a more concrete example of a promise that generates a random number after a 1 second delay. It succeeds when the generated number is even and fails when the generated number is odd.

function generateRandomNumber() {
  return new Promise(function(resolve, reject) {
    setTimeout(function() {
      const nb = Math.floor(Math.random() * 10); // random number between 0 and 10
      if (nb % 2 == 0) {
        resolve(nb);
      } else {
        reject({ message: "odd number", number: nb });
      }
    }, 1000);
  });
}

We will not use this Promise constructor however because most of the Service worker APIs provide functions that already return a promise.

Once we have a Promise object, its associated code starts to execute asynchronously. We can use the then() and reject() functions to execute a function when the promises succeeds (it calls resolve) or fails (it calls reject).

The following example illustrates how to handle the promise returned by the generateRandomNumber() function. Run online (opens new window)

const promise = generateRandomNumber(); // create a promise that generated a random number asynchronously
promise
  .then(function(number) {
    // this function is called when the promise succeds
    console.log(number);
  })
  .catch(function(error) {
    // this function is called when the promise fails
    console.error(error);
  });
console.log("Promise example"); // this message is shows first because the promise is async

We can abbreviate the promise call by extracting the then and catch handlers as functions. Run online (opens new window)

function handleSuccess(number) {
  console.log(number);
}
function handleFailure(message) {
  console.error(message);
}
generateRandomNumber()
  .then(handleSuccess)
  .catch(handleFailure);
console.log("Promise example"); // this message is shows first because the promise is async

The best feature of Promises is that they can be easily chained. The following example generates a new random number asynchronously only if the first promise succeeded. Run online (opens new window)

function handleSuccess(number) {
  console.log(number);
}
function handleFailure(message) {
  console.error(message);
}
generateRandomNumber()
  .then(handleSuccess)
  .then(generateRandomNumber)
  .then(handleSuccess) // chain a second promise and handle is result
  .catch(handleFailure); // if any of the previous calls fails, catch is called
console.log("Promise example"); // this message is shows first because the promise is async

Since ES2018 specification, there is a new operator that can be used to deal with promises with a more synchronous style. Instead of chaining then calls and callbacks, we can pause the function execution while waiting for a promise result, then store the result in a variable and resume function execution. This is called awaiting the result and uses the async/await keywords. With this method, the catch method is replaced by a try/catch block, just like you are used to catch synchronous exceptions.

The following code snippet transforms the last example to use async/await. Run online (opens new window)

// If we want to use await, we must be place the code in async function
// More reading https://github.com/tc39/proposal-top-level-await, https://gist.github.com/Rich-Harris/0b6f317657f5167663b493c722647221
async function mainAsync() {
  try {
    // create the promise asynchronously without blocking the thread
    // and wait for its result (the parameter passed to resolve)
    const nb1 = await generateRandomNumberAsync();
    console.log(nb1); // nb1 is not the promise but rather its result in case of success
    const nb2 = await generateRandomNumberAsync();
    console.log(nb2);
  } catch (error) {
    // this catch block is executed if any promise above fails
    console.error(error); // the error variable is the value passed to reject
  }
}
mainAsync(); // call the function that runs async code
console.log("Promise example with async / await");

This concludes this overview about promises and async/await. With this knowledge acquired, we can use the caching APIs of the service worker more serenely.

If you still feel uncomfortable with promises, here are some exercices: set 1 (opens new window), set 2 (opens new window), set 3 (opens new window)

# Exploring caching APIs

Of the APIs that the Service Worker has access to (opens new window), the one we are interested in is Cache API (opens new window). Indeed, it makes it possible to put in a persistent cache the requests/responses pairs with the method cache.put(request, response). It is also possible to pass one or more URLs as arguments to the cache.add and cache.addAll methods; they will be requested and the network response will be added to the cache. You can also delete cache entries with the cache.delete method.

The different caches are accessible through the caches variable from the Service Worker. It is the CacheStorage API (opens new window) which allows, among other things, to create / retrieve a cache object or to delete one with the caches.open and caches.delete functions.

Finally, another interesting method of the cache is match: it checks in all Cache objects managed by the CacheStorage if a request is identical to that passed in parameter. If so, it returns a Promise resolved with the cached response.

# Precaching critical static files

We will cache the essential static files of the application as soon as possible. The best timing to do this is when the install event is triggered, because it is only called once at Service Worker installation. This is called precaching.

  1. Go to sw.js in the install event callback as seen in step 2.
  2. Open the cache with caches.open('V1') (opens new window), which returns a Promise resolved with the cache object. The version number in the cache name will be useful for future updates.
  3. Once the cache is open, add to the cache with cache.addAll(['url1', 'url2', ...]) (opens new window). the URLs to the essential static files of our application: the root HTML page, the styles.css file and thescripts.js file.
  4. In order for the Service Worker to activate after precaching is complete, pass the Promise returned by cache.addAll as an argument to event.waitUntil () (opens new window), event being the installation event.

Reload the page and the Service Worker, making sure that the new version of the Service Worker replaces the old one as seen in step 2. We can then check that the files are added to the cache by looking at the Cache Storage section in the Application tab of Chrome Developer Tools.

Cache storage

# Response with cache in priority

Now that the files are cached, we have to indicate to use their cached version when the browser requests them. To do this, we will use the fetch event. The latter intercepts all requests made by clients who have installed the Service Worker. You can then return a custom response with event.respondWith, where event is the FetchEvent (opens new window). The event.respondWith (opens new window) function takes as a single argument a Promise that must be resolved with the response to return.

self.addEventListener("fetch", event => {
  console.log(`Request of ${event.request.url}`);

  // default behaviour: request the network
  event.respondWith(fetch(event.request));
});

We want to change the default behavior and return previously cached versions if they exist. Precisely, the caches.match (opens new window) function returns a Promise resolved with aResponse object, just like fetch (event.request). So we can replace one by the other and return the static files from cache instead of requesting them from the network.

  1. Add a callback for the fetch event, the same way you did forinstall in the previous step.
  2. Call the caches.match(event.request) method to search the cache for a cached response for the corresponding request.
  3. If no cache entry is found, the Promise is resolved with the value undefined. In this case, you need to request the network and return fetch(event.request) instead.
  4. Finally, return this Promise of Response to the client request by passing it as argument to event.respondWith()

# Testing offline

If everything has been done correctly, you should now be able to test the application in offline mode. Shut down your local server and try to reload the application. The cached page should be displayed.

# Put files in cache automatically

When offline, some non-essential static files can not be downloaded and have not been put in cache ; for example, the PWA logo in the header. Let's change our service worker code to put these files in cache automatically, without using precaching at install.

  1. Add this cache function below in the Service Worker code:
function cache(request, response) {
  if (response.type === "error" || response.type === "opaque") {
    return Promise.resolve(); // do not put in cache network errors
  }

  return caches
    .open(CACHE_NAME)
    .then(cache => cache.put(request, response.clone()));
}

Warning

An answer can only be read once, so it must be cloned with the .clone() method before caching it.

  1. In fetch event callback, add another then instruction after fetching network, then call the function cache declared before with the request and its response to put them in cache.

  2. Add one last then instruction to make sure the promise resolves with the network response as final value, as required by the event.respondWith function.

To test automatic cache, go back online and reload the app to put in cache the PWA logo. Check with the DevTools that it is correctly added to the cache, then go back offline and try to load this logo without any Internet connection.

TIP

Files loaded from an external domain that does not allow CORS requests, like the attendees pictures taken from Amazon and the font taken from Google, cannot be put in cache like the other static files. You will have to host these files on your domain or configure CORS on the external domain to be allowed to cache those.

# Cache updates

Caching static files raises a question: what happens if I add, delete, or modify these files on the server ?

As we have currently coded our Service Worker, we will always load the cached files, so new versions deployed on the server will never be used.

To handle this problem, one solution is to use a new cache with another name. The idea here would be to create a new V2 cache that contains the new files and remove the previous obsolete cache.

  1. In the install event callback, change the cache name toV2
  2. In the activate event callback, delete the old cache with caches.delete('V1')
  3. Pass the Promise returned bycaches.delete in event.waitUntil in order to wait for the cache to be removed before the end of the activation step
  4. (optional) - Improve your cache cleanup code by removing all caches that are not in your whitelist of used caches. You can browse all existing caches with the caches.keys() method

Flowsheet