Chapter 5. Embracing Offline-First

Over a decade ago, I cofounded a company called Wi-Ser. Our mission was to convince cafes and restaurant owners that this was 2004, and it was about time they provided free WiFi to their clients.

In trying to sell this revolutionary idea, we ran into two main objections.

The less technical skeptics asked us, “Why do we need to provide WiFi to our clients? Why do they need to be online while they are having coffee?”

A couple of more technical skeptics pointed out that they read that WiMAX is just around the corner—online connectivity was about to be solved.

Over a decade has passed since those days, and most of us have to come to realize that mobile connectivity isn’t going to be “solved” anytime soon.

Sitting at our home or office, with a reliable internet connection, it is easy to dismiss the problem. Connectivity issues seem like a problem of the less fortunate, halfway around the world. But this is an issue that affects us all, whether we are boarding a flight, landing in a foreign country with no local data plan, commuting in the metro, hiking, or even just sitting in that one room in our house where there never seems to be any reception (except when standing on a really high chair).

We have become so used to this, that “I am going into a tunnel” or “I am getting into an elevator” has become a joke. An excuse you say when you want to lose connectivity.

It is time to stop treating a loss of connectivity as an error state in our web apps. Offline and poor connectivity are inevitable states in our apps—ones we must plan for.

We embraced mobile-first once we realized we could no longer ignore the fact that users are visiting our sites on mobile screens. We had to come to terms with the fact that we can no longer build websites that only look great on a 15-inch screen. We learned to consider mobile devices first, and build the experience up from there.

And yet in this increasingly mobile world where connectivity is never assured and bandwidth is at a premium, we have grown complacent.

As our sites have become more and more sophisticated, turning into full-fledged web apps, the average size of our sites has ballooned as well. It has become quite common to find web pages consisting of multiple megabytes, despite their content being purely static.

We have transitioned into a mobile-first world, and yet our approach to connectivity and bandwidth is still rooted in the days of the desktop.

It is time to start thinking offline-first.

What Is Offline-First?

Traditional web apps used to be entirely dependent on the server. All data, content, design, and application logic were stored on the server. The client was just there to render some HTML to the screen. But as web apps evolved, more and more logic and power was transferred to the client. Web apps started doing data manipulations, template rendering, and more. But unlike native apps, our web apps remained completely dependent on the server. Any disruption in connectivity would cause the app to fail completely.

Offline-first is about accepting a simple truth: offline and low connectivity conditions are inevitable. These conditions should be treated not as a catastrophic failure, but as just another possible state in the life of your web app. A state you should plan for and handle gracefully.

Embracing offline-first means accepting that while some aspects of your app might stop working when the user is offline, many others no longer have to.

Important

Let’s look at the sample messaging app introduced in Different Challenges, Different Approaches. Traditionally, if the user visited this web app while he was offline, the browser would simply show him an error. In a native app, this horrible user experience would be unacceptable. There is no reason we shouldn’t hold the web up to the same user experience standards.

A modern messaging PWA can cache its interface and logic locally, as well as the content of the most recent messages. It can then display the full interface along with the last cached content to the user. Yes, the content may be a bit stale (and it is important to communicate this to the user), but it can still be useful. This is an app that no longer handles a loss of connectivity as a catastrophic failure. It gives the user the best experience possible for his current conditions.

Another fundamental aspect of offline-first is handling these changes in connectivity gracefully. Handling a dropped connection gracefully means communicating to the user that some functionality may not be available, or that the data she is looking at may be a few hours old but still exposing as much functionality as possible. Even if you have built a web app that works entirely offline, handling a change in connectivity gracefully might mean reassuring the user that she can still use the app and that her data will not be lost.

Important

Going back to our messaging app example, the developer would also need to decide how to handle users sending new messages while they are offline. The app could “break gracefully,” disabling the input field and letting the user know that he cannot send a message while offline. Or it could let the user enter new messages, storing them in a local database in the browser (see Chapter 6) and sending them as soon as a connection is reestablished. The app could even use background sync (see Chapter 7) when sending messages (whether the user is offline or online) to guarantee messages are always delivered, regardless of changes in connectivity.

Mobile-first means always giving the user the best possible experience for his device.

Offline-first means always giving the user the best possible experience for his current network conditions.

Common Caching Patterns

By the end of this chapter, we will have modified the Gotham Imperial Hotel website so that it fully embraces an offline-first approach.

Before we can decide on a caching strategy for the different parts of our site, we need to familiarize ourselves with a few of the more common design patterns used for caching.

Different patterns fit different situations, and most apps would use a few different ones. For example, if we are creating a weather app, we would probably want to adopt a pattern that always loads the latest weather data from the network, and only if the network fails try to load it from the cache. On the other hand, for the icons showing the different weather conditions, we might prefer to adopt a pattern that always gets the icon from the cache first and only tries the network if they are not found in the cache.

Weather conditions are an example of a rapidly changing resource. One where it is critical to show the most up-to-date data. The icon depicting a partly cloudy sky is not time sensitive nor does it change.

Let’s explore a few of the more common caching patterns.1

Cache only

Respond to all requests for a resource with a response from the cache. If it is not found in the cache, the request will fail. This pattern assumes that the resource has been cached before, most likely as a dependency during the service worker’s installation.

This is useful for static resources that do not change between releases, such as logos, icons, and stylesheets. This does not mean you can never change them. It simply means they do not change within the lifetime of a single version of your app.

If these files do change, you can update them by renaming them and storing these new files in the cache. This is similar to the classic caching practice (unrelated to service workers) of changing the names of all static files in every version (e.g., style.v1.0.3.css or main_ae3f7.js) and configuring the server to serve those files with a very long, or even indefinite, cache expiration date.

If you choose not to change filenames, you can fetch and cache these files again by releasing a new version of your service worker, and caching them during the service worker’s activation event (see Chapter 4):

self.addEventListener("fetch", function(event) {
  event.respondWith(
    caches.match(event.request)
  );
});
Cache, falling back to network

Similar to cache only, this pattern will respond to requests with content from the cache. If, however, the content is not found in the cache, the service worker will attempt to fetch it from the network and return that:

self.addEventListener("fetch", function(event) {
  event.respondWith(
    caches.match(event.request).then(function(response) {
      return response || fetch(event.request);
    })
  );
});
Network only

The classic model of the web. Try to fetch the request from the network. If it fails, the request fails. This is useful for things you decide never to cache (e.g., analytics pings).

You will rarely use this pattern as you can perform network only requests by simply ignoring their fetch event in your service worker and letting their default behavior play out. If, however, you find yourself needing to do a network only request programmatically, the following code might help:

self.addEventListener("fetch", function(event) {
  event.respondWith(
    fetch(event.request)
  );
});
Network, falling back to cache

Always fetch the request from the network. If the request fails, return the version from the cache. If it is not found in the cache, the request will fail:

self.addEventListener("fetch", function(event) {
  event.respondWith(
    fetch(event.request).catch(function() {
      return caches.match(event.request);
    })
  );
});

The user always gets the most up-to-date content available for her current connection. This is great for content that changes frequently, or when it is important to show the most up-to-date response.

Cache, then network

Display data from the cache immediately while checking the network for a more up-to-date version. As soon as a response is returned from the network, check if it is newer than the cache and update the page with the fresh content.

While this may seem like a best-of-all-worlds approach, combining a fast response from the cache with up-to-date content from the network as soon as it is available, it does come with a price.

You will have to modify your app to make two requests, display cached content, and finally update the page with newer content when it becomes available. More importantly, this pattern might raise new UX challenges for your app. While it is easy to simply change an image with a more up-to-date one when it is available, what if the content you are updating is the text of a document the user is editing? What if you have to change the second sentence after the user has already begun editing it? What is the best way to make the change, and how do you communicate it to the user?

Generic fallback

When the content the user asked for could not be found in the cache, and the network is not available, this pattern returns an alternate “default fallback” from the cache instead of returning an error.

One common use case for this is returning a generic image instead of a specific one. For example, when a user’s avatar is not found in cache, and the network is unavailable, displaying a generic avatar instead of leaving a broken image in your app is a great way to gracefully handle a change in connectivity.

This pattern is usually used together with others as a final fallback. The following example shows how it can be used with the network, falling back to cache pattern to create a network, falling back to cache, falling back to generic pattern:

self.addEventListener("fetch", function(event) {
  event.respondWith(
    fetch(event.request).catch(function() {
      return caches.match(event.request).then(function(response) {
        return response || caches.match("/generic.png");
      });
    })
  );
});

Using PWAs as the Door to Emerging Markets at Twitter

For Twitter, progressive web apps have been a boon—especially for entering emerging markets where big growth opportunities still abound. These markets are often characterized by expensive, slow, and unreliable connections.

Twitter’s progressive web app combines many benefits over their native app. It weighs a mere 400 KB (about 2.5% of the Android app), uses less battery, and launches several seconds faster from the homescreen than the native app. Most importantly, it combines all of these benefits without sacrificing any of the features of the native app.

All of these advantages give Twitter a significant edge in markets where slower feature phones and unreliable, costly connections are the norm.

Mix and Match: Creating New Patterns

Now that we have seen a few of the more common caching patterns, let’s examine some ideas of how we can combine them to create new methods for caching and serving content.

Cache on demand

For resources that do not change often and that we do not want to cache during the service worker’s install event, we can extend the cache, falling back to network pattern to save requests that have been returned from the network to the cache.

This effectively creates a system for caching resources on-demand. When a resource is first requested, it won’t be found in the cache. The service worker will retrieve it from the network, store it in the cache, and then return it. The next time this resource is requested again, it will be returned instantaneously from the cache:

self.addEventListener("fetch", function(event) {
  event.respondWith(
    caches.open("cache-name").then(function(cache) {
      return cache.match(event.request).then(function(cachedResponse) {
        return cachedResponse || fetch(event.request).then(
          function(networkResponse) {
          cache.put(event.request, networkResponse.clone());
          return networkResponse;
        });
      })
    })
  );
});

The Case for Cloning—Using a Response More Than Once

When looking at the code for the last three patterns, you may have noticed something new. Before we saved our response to the cache, we called a method called clone on it:

fetch(request).then(function(response) {
  cache.put(request, response.clone());
  return response;
});

Why did we put a cloned copy of the response into the cache instead of just the response itself?

This actually has nothing to do with the fact that we used cache.put(). We have already placed responses in the cache in the past and did not have to clone them first. The real reason is that we intend to use the response more than once.

You can think of responses as being written on a physical piece of paper. Imagine the owner of the Gotham Imperial Hotel, heir to the Dwayne family fortune, preparing a speech and writing it on a piece of paper. Just as he is about to go on stage, he hands the speech to his assistant to place in storage. Once he gets on stage, he might find himself standing in front of a packed room holding nothing—unless he made a copy of the speech first and gave that to his assistant instead.

Responses act similarly. You can pass them on from one place to the next (e.g., using a return statement), but if you intend to use them more than once (e.g., placing them in cache and responding to an event with them), make sure you have a copy of them made by using the clone command.

Cache, falling back to network with frequent updates

For resources that do change from time to time, but where showing the latest version is less important than returning a fast response (e.g., user avatars), we can modify the cache, falling back to network pattern to always fetch the requested resource from the network even when it is found in the cache. This pattern delivers a fast response from the cache, while fetching a more up-to-date version and caching it in the background. Any changes to the resource fetched from the network will be available the next time the user requests this resource. This combines the benefits of a fast response with a relatively up-to-date response (the content shown is as fresh as it was the last time you requested it):

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

Our event handler begins with event.respondWith. The rest of our code builds that response.

We begin by opening the cache and trying to find a matching request in it. Whether a match is found or not, the promise returned by cache.match will be resolved and the then callback function will be called. Our callback begins by creating a new fetch request for the requested resource, saving it to the cache, and returning that response. The last line returns to event.respondWith either the cached response or, if that wasn’t found, a promise to return the response from the network.

When we call fetch, it returns a promise and continues executing the script while the fetch is done asynchronously. This allows us to return the cachedResponse in the next line without waiting for the fetch to complete, or return the promise created by fetch (a promise that resolves to the file from the network).

Network, falling back to cache with frequent updates

When it is important to always serve the latest version of a resource available, we can use this twist on the network, falling back to cache pattern. Like the original pattern, this one always attempts to fetch the latest version from the network, falling back to the cached version only if the network request fails. In addition, every time the network is accessed successfully, it updates the cache with the network response:

self.addEventListener("fetch", function(event) {
  event.respondWith(
    caches.open("cache-name").then(function(cache) {
      return fetch(event.request).then(function(networkResponse) {
        cache.put(event.request, networkResponse.clone());
        return networkResponse;
      }).catch(function() {
        return caches.match(event.request);
      });
    })
  );
});

Planning Our Caching Strategy

So far, our approach to handling connectivity issues in the Gotham Imperial Hotel’s app was based solely on the network, falling back to cache pattern. We used this pattern to cache a simplified version of our home page, and when we detected a network error, served that to our users.

This was already a marked improvement over our original app. It is something we can ship and know that it provides added value for our users.

Now that we have learned about the different caching patterns, we can take this further.

It is time to take everything we have learned so far and adopt an offline-first approach for the Gotham Imperial Hotel app. When we are done, the page itself will load instantaneously, while resources in it that change from time to time will load from the network or from the cache if the network is not available.

Let’s examine our home page.

Our home page is composed of a static index.html file that rarely changes between versions. It accesses a number of static images, stylesheets, and JavaScript files. The static files used by index.html can all be cached during installation and are perfect for the cache, falling back to network pattern. This will give us a much faster load time for users who are online, offline, or anywhere in between.

What about the index.html file itself? Since this file rarely changes between versions, we might consider a cache, falling back to network pattern. This approach does come with a significant downside in our case. If this file gets updated, we will have to update our service worker as well to make sure the new file is fetched and cached.

To make matters worse, our users won’t see the new version until the old service worker releases control of the page and a new service worker serving the new file becomes active. You can see how this plays out during each visit in Table 5-1.

Table 5-1. State of the page and service worker in each consecutive visit
Visit Note Service worker index.html

1st

SW v1 installs and caches HTML v1

HTML v1 served from network

2nd

A new version of the service worker (v2), and a new HTML file (v2) is available

SW v2 installs and caches HTML v2. SW v1 is still in control of the page

HTML v1 served from cache

3rd

SW v1 had a chance to release control of the page

SW v2 activates and controls the page

HTML v2 served from cache

This means that even if we update the service worker with a new HTML file, it won’t be shown to the user until his next visit to our app.

Chapter 4 goes into detail of why this is happening and how the service worker moves between these states.

Let’s consider our options for caching index.html:

  1. Serve it using the cache, falling back to network pattern. This has the downside of possibly not showing the latest version available, even though it might already be cached. It is, however, a fast and bandwidth-efficient solution.

  2. Serve it using the network, falling back to cache pattern. This will always show the latest file available. The downside is that we are passing on the chance to improve the load time of an HTML file that we may already have in the cache.

  3. Serve it using the cache, falling back to network with frequent updates pattern. Similar to option 1, this always serves the index.html from the cache, providing us with a very fast response time. In addition, it also checks for an updated index.html file and updates the cache if it exists without requiring us to release a new service worker version. The next time the user loads the page, she will see the latest file. This approach combines a fast response time with an almost always up-to-date file. It does, however, use just as much bandwidth as option 2, or not having a service worker at all.

For the Gotham Imperial Hotel home page, I decided to go with option 3. This approach gives us the benefit of a home page that loads instantly, no matter what the user’s connection is. The main downside to this is that the home page shown may sometimes be an older one until the user refreshes the page (not a big deal in this case, since all dynamic data that may change is cached using patterns that will serve up the most up-to-date data). The second downside is that it fetches the HTML from the network on every visit, even though it may already be in the cache (again, not a big deal as the file is relatively small, and the server can send Expires and ETag headers to make sure it is cached in the HTTP cache).

Looking further down our home page, we see a map created using the Google Maps JavaScript API. This interactive map is loaded from Google’s servers every time our page loads. Since we can’t cache all of Google Maps’ logic and data, we will update our service worker to detect when the Google Maps JavaScript file fails to load and serve an alternate JavaScript file. This file will display a static image with a map instead of a dynamic, interactive map widget. This is progressive enhancement in action. Offline users will see the map as a static image, while online users will get a fully interactive map.

Our home page also loads a JSON file with a list of upcoming events taking place at the hotel. This is data that can change at any time, and we would like for our users to always see the most up-to-date version. We will use the network, falling back to cache with frequent updates pattern to serve this file. This makes sure we always serve the latest data we have access to according to network conditions: live data if the user is online, or the last version we cached if he isn’t.

The events JSON file also includes references to a number of image files, each representing a different event. Since these files are not cached during the installation phase, we can use the cache on demand pattern. Every time one of these files is requested, we will attempt to fetch it from the cache. If it is not found in the cache, we will fetch it from the network, store it in the cache for next time, and return it to the page.

To make sure we never have a broken image on our page, we will also modify our caching code for the event images to show a default fallback image if an image is not found in the cache, and the network is not available. This default image will be cached during the installation phase as an install dependency.

Finally, we will set up a rule to make sure analytics pings are sent directly to the network without any caching or fallback. If the user is offline, these pings should fail.

Let’s summarize our caching strategy for the home page:

  1. Return index.html using the cache, falling back to network with frequent updates pattern.

  2. Return all static files required to display the home page using the cache, falling back to network pattern.

  3. Return the Google Maps JavaScript file from the network. If the request fails, return an alternate script that works offline.

  4. Return the events.json file using the network, falling back to cache with frequent updates pattern.

  5. Return event image files using the cache on demand pattern, falling back to a default generic image if the network isn’t available and the image isn’t cached.

  6. Let analytics pings go through untouched.

Implementing Our Caching Strategy

Before you begin, make sure that your code is in the state we left it in at the end of Chapter 4 by running the following commands in your command line:

git reset --hard
git checkout ch05-start

Now we can get started on implementing our new caching strategy by updating the service worker to cache and serve our full home page, along with all of the static assets required to render it.

Replace the code in serviceworker.js with the following code:

var CACHE_NAME = "gih-cache-v4";
var CACHED_URLS = [
  // Our HTML
  "/index.html",
  // Stylesheets
  "/css/gih.css",
  "https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css",
  "https://fonts.googleapis.com/css?family=Lato:300,600,900",
  // JavaScript
  "https://code.jquery.com/jquery-3.0.0.min.js",
  "/js/app.js",
  // Images
  "/img/logo.png",
  "/img/logo-header.png",
  "/img/event-calendar-link.jpg",
  "/img/switch.png",
  "/img/logo-top-background.png",
  "/img/jumbo-background.jpg",
  "/img/reservation-gih.jpg",
  "/img/about-hotel-spa.jpg",
  "/img/about-hotel-luxury.jpg"
];

self.addEventListener("install", function(event) {
  event.waitUntil(
    caches.open(CACHE_NAME).then(function(cache) {
      return cache.addAll(CACHED_URLS);
    })
  );
});

self.addEventListener("fetch", function(event) {
  event.respondWith(
    fetch(event.request).catch(function() {
      return caches.match(event.request).then(function(response) {
        if (response) {
          return response;
        } else if (event.request.headers.get("accept").includes("text/html")) {
          return caches.match("/index.html");
        }
      });
    })
  );
});

self.addEventListener("activate", function(event) {
  event.waitUntil(
    caches.keys().then(function(cacheNames) {
      return Promise.all(
        cacheNames.map(function(cacheName) {
          if (CACHE_NAME !== cacheName && cacheName.startsWith("gih-cache")) {
            return caches.delete(cacheName);
          }
        })
      );
    })
  );
});

The code is similar to what we had at the end of Chapter 4, except for two changes.

First, we replaced the contents of the CACHED_URLS array to contain index.html instead of sw-index.html, along with all the static files required to display it.

Our second change is in the fetch listener, where we now return index.html instead of sw-index.html from the cache.

These two small changes are enough to give our offline users a home page experience that is almost identical to the online one.

Let’s take it further and improve it so that index.html and all the static files required to display it load instantly, even when the user is online.

Replace the code of the fetch event listener in serviceworker.js with the following:

self.addEventListener("fetch", function(event) {
  var requestURL = new URL(event.request.url);
  if (requestURL.pathname === "/" || requestURL.pathname === "/index.html") {
    event.respondWith(
      caches.open(CACHE_NAME).then(function(cache) {
        return cache.match("/index.html").then(function(cachedResponse) {
          var fetchPromise =
            fetch("/index.html")
            .then(function(networkResponse) {
              cache.put("/index.html", networkResponse.clone());
              return networkResponse;
            });
          return cachedResponse || fetchPromise;
        });
      })
    );
  } else if (
    CACHED_URLS.includes(requestURL.href) ||
    CACHED_URLS.includes(requestURL.pathname)
  ) {
    event.respondWith(
      caches.open(CACHE_NAME).then(function(cache) {
        return cache.match(event.request).then(function(response) {
          return response || fetch(event.request);
        });
      })
    );
  }
});

Our new fetch event handler now behaves differently according to each request’s URL.

The first case we test for is for requests to either the root of our domain or /index.html (both are valid requests for our home page). We handle this request with the cache, falling back to network with frequent updates pattern. The code looks for index.html in the cache, then whether it was found or not the code starts fetching and caching the latest version from the network. It then returns either the cached version immediately, or a promise to return the response from the network if it wasn’t found in the cache.

Because fetch runs asynchronously, the response from the cache can be returned even before the fetch resolves.

This pattern gives us both an instant response (a few milliseconds) from the cache, while guaranteeing a relatively up-to-date HTML file.

The details of this code are explained in the “Common Caching Patterns”.

We end our event handler by testing if the request matches any of the URLs we cached during the service worker’s installation. If so, we respond to the event with a response from the cache. If it is not found in the cache, we attempt to return it from the network (this is the cache, falling back to network pattern).

Requests that match neither of these two conditions will simply pass through our service worker untouched and behave normally.

new URL(urlString, [baseURL])

Our fetch handler’s main conditional statement decides how to handle different requests by testing their URLs. In the past, this would have involved some fairly nasty regular expressions to accomplish. Thankfully, the relatively new URL interface allows us to do this with ease:

// These three statements return the same URL
var url_1 = new URL("https://gothamimperial.com/index.html");
var url_2 = new URL("/index.html", "https://gothamimperial.com");
var url_3 = new URL("/index.html", url_1);

// All of the following statements are true
url_1.href     === "https://gothamimperial.com/index.html";
url_1.protocol === "https:";
url_1.hostname === "gothamimperial.com";
url_1.pathname === "/index.html";

We just accomplished the first and second caching goals we set for ourselves in “Planning Our Caching Strategy”. Let’s add a few more conditions to our event handler to take care of individual customizations for different resources.

Replace the code in serviceworker.js with the following code:

var CACHE_NAME = "gih-cache-v5";
var CACHED_URLS = [
  // Our HTML
  "/index.html",
  // Stylesheets
  "/css/gih.css",
  "https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css",
  "https://fonts.googleapis.com/css?family=Lato:300,600,900",
  // JavaScript
  "https://code.jquery.com/jquery-3.0.0.min.js",
  "/js/app.js",
  "/js/offline-map.js",
  // Images
  "/img/logo.png",
  "/img/logo-header.png",
  "/img/event-calendar-link.jpg",
  "/img/switch.png",
  "/img/logo-top-background.png",
  "/img/jumbo-background-sm.jpg",
  "/img/jumbo-background.jpg",
  "/img/reservation-gih.jpg",
  "/img/about-hotel-spa.jpg",
  "/img/about-hotel-luxury.jpg",
  "/img/event-default.jpg",
  "/img/map-offline.jpg",
  // JSON
  "/events.json"
];
var googleMapsAPIJS = "https://maps.googleapis.com/maps/api/js?key="+
  "AIzaSyDm9jndhfbcWByQnrivoaWAEQA8jy3COdE&callback=initMap";

self.addEventListener("install", function(event) {
  event.waitUntil(
    caches.open(CACHE_NAME).then(function(cache) {
      return cache.addAll(CACHED_URLS);
    })
  );
});

self.addEventListener("fetch", function(event) {
  var requestURL = new URL(event.request.url);
  // Handle requests for index.html
  if (requestURL.pathname === "/" || requestURL.pathname === "/index.html") {
    event.respondWith(
      caches.open(CACHE_NAME).then(function(cache) {
        return cache.match("/index.html").then(function(cachedResponse) {
          var fetchPromise = fetch("/index.html")
            .then(function(networkResponse) {
              cache.put("/index.html", networkResponse.clone());
              return networkResponse;
            });
          return cachedResponse || fetchPromise;
        });
      })
    );
  // Handle requests for Google Maps JavaScript API file
  } else if (requestURL.href === googleMapsAPIJS) {
    event.respondWith(
      fetch(
        googleMapsAPIJS+"&"+Date.now(),
        { mode: "no-cors", cache: "no-store" }
      ).catch(function() {
        return caches.match("/js/offline-map.js");
      })
    );
  // Handle requests for events JSON file
  } else if (requestURL.pathname === "/events.json") {
    event.respondWith(
      caches.open(CACHE_NAME).then(function(cache) {
        return fetch(event.request).then(function(networkResponse) {
          cache.put(event.request, networkResponse.clone());
          return networkResponse;
        }).catch(function() {
          return caches.match(event.request);
        });
      })
    );
  // Handle requests for event images.
  } else if (requestURL.pathname.startsWith("/img/event-")) {
    event.respondWith(
      caches.open(CACHE_NAME).then(function(cache) {
        return cache.match(event.request).then(function(cacheResponse) {
          return cacheResponse ||
            fetch(event.request).then(function(networkResponse) {
              cache.put(event.request, networkResponse.clone());
              return networkResponse;
            }).catch(function() {
              return cache.match("/img/event-default.jpg");
            });
        });
      })
    );
  // Handle analytics requests
  } else if (requestURL.host === "www.google-analytics.com") {
    event.respondWith(fetch(event.request));
  // Handle requests for files cached during installation
  } else if (
    CACHED_URLS.includes(requestURL.href) ||
    CACHED_URLS.includes(requestURL.pathname)
  ) {
    event.respondWith(
      caches.open(CACHE_NAME).then(function(cache) {
        return cache.match(event.request).then(function(response) {
          return response || fetch(event.request);
        });
      })
    );
  }
});

self.addEventListener("activate", function(event) {
  event.waitUntil(
    caches.keys().then(function(cacheNames) {
      return Promise.all(
        cacheNames.map(function(cacheName) {
          if (CACHE_NAME !== cacheName && cacheName.startsWith("gih-cache")) {
            return caches.delete(cacheName);
          }
        })
      );
    })
  );
});

The code in this example introduces a few changes.

First, it adds a number of new files to the CACHED_URLS array (including "/js/offline-map.js", "/img/event-default.jpg", "/img/map-offline.jpg", and "/events.json"). Next, it sets a new googleMapsAPIJS variable with the URL of the Google Maps API we need to call (we are setting it once here to avoid repetition later). Finally, it adds a few conditions to the fetch event handler.

The first and last conditions remain unchanged. Between those two, four new conditions have been added. Let’s go over them one by one.

Our first new condition looks for requests for the Google Maps JavaScript API:

if (requestURL.href === googleMapsAPIJS) {
  event.respondWith(
    fetch(
      googleMapsAPIJS+"&"+Date.now(),
      { mode: "no-cors", cache: "no-store" }
    ).catch(function() {
      return caches.match("/js/offline-map.js");
    })
  );
}

If the current request asks for the Maps JavaScript file, we attempt to fetch it from the web. If the user is offline and that request fails, we return an alternate JavaScript file from the cache. This simple JavaScript file (called offline-map.js) contains only a single line of code:

document.getElementById("map-container").classList.add("offline-map");

If the user is offline, this code will run instead of the Google Maps API code and will add a class called offline-map to the map-container div. If you examine our CSS file, you will see that this class sets that div’s background image to a static image containing our map.

Note that we also added both the static map image and this new JavaScript file to our CACHED_URLS array to make sure they are both cached when the service worker installs.

Two final things to note about our offline map code. When fetching the Google Maps JavaScript file, we have to fetch it in no-cors mode; otherwise Google’s server will reject the request (see Appendix C). Second, because Google’s servers return the Maps API JavaScript file with headers that cause the browser to always attempt to return it from the HTTP cache, we have to make sure it is always fetched from the network. Otherwise, our fetch won’t fail, and we would get the Google Maps controls (from the cache), but no map under it (the map data isn’t cached). We accomplish this by fetching with the cache option set to no-store, which skips the cache completely. Unfortunately, at the time of writing, this option is still not supported in all browsers, so we also add a cache-busting timestamp to each request’s query string to make sure each request is unique and will skip the cache. We do this by appending the current time to each request’s URL.

The second new condition in the fetch handler handles requests to the JSON file containing our event data:

if (requestURL.pathname === "/events.json") {
  event.respondWith(
    caches.open(CACHE_NAME).then(function(cache) {
      return fetch(event.request).then(function(networkResponse) {
        cache.put(event.request, networkResponse.clone());
        return networkResponse;
      }).catch(function() {
        return caches.match(event.request);
      });
    })
  );
}

Because this data changes frequently, and we would like to always serve the latest data we have access to, we chose the network, falling back to cache with frequent updates pattern.

We begin by opening our cache (we will need it whether the request to the network succeeds or not). We then try to fetch the request from the network. If the request succeeds, we place the response in the cache and return it. If it does not, we look for a cached response and return that instead.

Caution

If you did not skip Chapter 4, you may have noticed a problem here. We only cache the events.json file when we intercept the fetch request. This only happens when the service worker already controls the page. In other words, when the user visits the page for the first time, this file isn’t cached. Only on the user’s second visit will the browser’s attempt to fetch this file be captured by the service worker. If the user was offline on his second visit, the file won’t be found in the cache.

Since our service worker depends on having this file in the cache, we can solve this issue by adding events.json to the CACHED_URLS array. This ensures that it will be cached when the service worker installs. It will then be kept up-to-date on every consecutive visit by the code we just added.

We continue with a condition that handles requests for event images:

if (requestURL.pathname.startsWith("/img/event-")) {
  event.respondWith(
    caches.open(CACHE_NAME).then(function(cache) {
      return cache.match(event.request).then(function(cacheResponse) {
        return cacheResponse ||
          fetch(event.request).then(function(networkResponse) {
            cache.put(event.request, networkResponse.clone());
            return networkResponse;
          }).catch(function() {
            return cache.match("/img/event-default.jpg");
          });
      });
    })
  );
}

Because these images change frequently, and during development we have no way of knowing which events our clients at the hotel are going to be hosting, we will cache these on demand.

Every time we detect a request for an event image, we begin by opening our cache and trying to find it. We then either return that image if it was found in the cache or attempt to fetch it from the network. If the image was fetched successfully, we place it in the cache for future use and return it.

Caution

Just like in our last condition, this presents a problem if the user is offline on her second visit to our page. In this case, the user will have the events.json file cached and try and display the event images. Unfortunately, they haven’t been cached yet, nor can they be fetched from the network. To handle this edge case, we use the generic fallback pattern. If the image is not in the cache and cannot be fetched from the network, we fall back to a generic event image (Figure 5-1).

Don’t forget to add the fallback image ("/img/event-default.jpg") to the CACHED_URLS array to make sure it is cached when the service worker installs.

Fallback images shown when images not found in cache
Figure 5-1. Fallback images shown when images not found in cache

The last condition we added handles requests to Google Analytics, and always responds with content from the network:

if (requestURL.host === "www.google-analytics.com") {
  event.respondWith(fetch(event.request));
}

This code is a bit superfluous. We could have simply removed it and let the browser handle the request using its default behavior (which is the same as what we hardcoded here) to achieve the same result.

In the case of the Gotham Imperial Hotel, this code could have been removed. But in your app, you might run into certain cases where you might want to explicitly define this. For example, your code might have a catch-all that handles all requests, and you would like to handle some exceptions this way.

The Washington Post—Predictive Caching

For the team at the Washington Post, getting people to read more articles at each visit is vital; making sure pages load as fast as possible is key to that.

Using many of the patterns described, the team has been able to squeeze out many performance gains from their progressive web app, but it took some out-of-the-box thinking to find the biggest speed boost—predictive caching.

When you read an article on the Washington Post, instead of caching the most popular articles on the site, or even the most popular articles from the category you are currently reading, the site’s service worker will cache the text, images, and even the first few seconds of the video you are most likely to read or watch next—namely, the ones linked directly from the current article.

This change alone has allowed the team to improve the load time of the next article to around 100 ms. An improvement that directly contributes to more articles read every month, more ad views, and eventually more paying subscribers.

Application Shell Architecture

In planning our caching strategy so far, we came up with an approach that made sense for content sites. But many progressive web apps you will work on will look a lot less like traditional content sites and more like, well, apps. Let’s turn our attention now to how you might approach caching and serving a more dynamic web app.

The same tools and techniques we have used so far still apply here. We will take all that we have learned and use it to implement a caching strategy that makes sense for web apps—the application shell architecture (also known as app shell).

App shell is not a revolutionary idea. In fact, there is a good chance that you are already structuring your web apps using a similar approach. Many JavaScript frameworks enforce a separation among an app’s content, its user interface, and the logic needed to load, display, and control both. The app shell architecture encourages you to further disconnect the basic logic and resources needed to render your app’s most basic interface from the rest of your app. It encourages you to render as light a shell as possible to the user as soon as possible, populating it with content and additional functionality as it becomes available. It prioritizes the display of structure and content that appears “above the fold” over those that can be put off for later.

App shell’s goal is to present the user with a meaningful experience as soon as possible. A well-designed progressive web app that implements app shell will load and show its basic interface in milliseconds.

Important

Let’s turn our attention back to our messaging app. You may decide that your app’s minimal shell is a header with the app’s logo, some basic controls, and an input field to enter new messages (Figure 5-2).

The minimal shell shown on the left side of the image can load very quickly on the user’s first visit. It is then cached so that in subsequent visits it can load in mere milliseconds, regardless of the user’s connection. As soon as this shell is rendered, the app can load fresh content from the network, as well as additional scripts needed to enable the rest of the app’s functionality.

This strategy allows you to create an app that responds almost instantaneously. It presents a UI to the user as soon as possible, instead of having him stare at an empty screen, waiting for the network to respond. The user may even begin typing a new message while the rest of the content and functionality loads in the background.

App shell and full experience compared
Figure 5-2. App shell and full experience compared

When planning your app’s shell, strive to serve the minimal amount of HTML, CSS, JavaScript, and images needed to render the basic user interface. Keep this shell as lean as possible so that it loads and runs as quickly as possible when users visit the app for the first time. It should then be stored immediately in the cache so that on consecutive visits it loads from the cache before a single network call is even made. This will allow your app’s interface to render in milliseconds. Once this initial shell has been displayed to the user, the app can populate it with content and expand it with more functionality.

Don’t forget that one of the core strengths of the web is the ability to deep-link directly to content. Keep in mind when planning your app shell that users may not always start their visit on the home page. The app shell should be relevant whether the user begins her journey on your home page or your user-management page. In Figure 5-2, you can see that the app shell will work whether the users start their journey on the timeline, mentions, or messages page.

By embracing this strategy, you will be able to create apps that load almost instantaneously, presenting a UI to the user as soon as possible, instead of having him stare at an empty page, waiting for the network to respond. You will create an experience that is more like what users have come to expect from native apps than from the web.

Including Content in the Initial Render

There is no rule saying apps using an app shell architecture should only display an empty shell when they first render to the page, then wait for the network before displaying any content. Each case is different, and it may make sense for your app to display cached content as part of the initial render along with the shell—even if that content may be stale.

Before including content in your initial render, ask yourself a few questions first:

  1. Will rendering potentially stale content from the cache and then updating it seconds later with fresh content from the network hurt or improve the user experience?

  2. Will retrieving and rendering this cached content significantly impact the initial load time and rendering speed of the app?

Important

In the case of our sample messaging app, we may decide that storing old messages in the cache and including them as part of our initial render improves the user experience. Messages that are already cached can be quickly rendered, and the experience of older messages being pushed down by newer ones as they arrive is a part of the app’s normal flow (Figure 5-3).

Chapter 6 looks at how to store data, like these messages, in a local database, and use them to populate an app shell with content.

Combining the app shell with cached content in the initial render
Figure 5-3. Combining the app shell with cached content in the initial render

As we have already seen, there are no hard rules on what should or shouldn’t go into the app shell (nor is it an architecture that fits all apps). When planning your app’s basic shell, ask yourself what components are absolutely critical to making the first render meaningful? Which components do not rely on any changing data and can always be served from cache? Is there any heavy logic that can be delayed and loaded after the basic interface has rendered? What would a native app developer do?

Implementing App Shell

So far we only looked at the Gotham Imperial Hotel’s home page. Let’s turn our attention to the user account page.

The user account page is shown when users click the My Account link on the top right of the app, or when they attempt to make a new reservation. It is a simple, single-page app that includes controls for making reservations, and it loads and renders a list of events and the user’s reservations.

It is a perfect candidate for adopting an app shell architecture.

When planning our caching strategy, the first step is to look at the various components that make up our app. What parts of our app can be cached and rendered immediately as part of the application shell?

  1. The basic layout of the page contains simple HTML markup and a simple stylesheet. Both of these together can be cached and rendered relatively fast.

  2. The header and footer contain a PNG file with the hotel’s logo. Because this logo is an important part of the hotel’s brand and is a relatively small file (7 KB), we will include it in our app shell.

  3. The page header contains a large background image with the Gotham skyline. This is a great example of an image that can be loaded later and does not need to be a part of our app shell.

  4. Both the data for the reservations list and the events list load using Ajax. These can be added to the page after the initial app shell loads and renders.

Our account page is already structured to render a minimal shell and load the rest of the content dynamically (Figure 5-4). Implementing our caching strategy is now a simple matter of caching three additional requests (the account page HTML, its JavaScript file, and the reservations JSON file) when the service worker installs, and adding two conditions to our fetch listener to handle serving of my-account.html and reservations.json.

bpwa 0504
Figure 5-4. The account page’s empty application shell

In serviceworker.js, add "/my-account.html", "/js/my-account.js", and "/reservations.json" to the CACHED_URLS array.

In the same file, add the following two conditions to the fetch event handler after the condition that checks for requests for index.html:

} else if (requestURL.pathname === "/my-account") {
  event.respondWith(
    caches.match("/my-account.html").then(function(response) {
      return response || fetch("/my-account.html");
    })
  );
} else if (requestURL.pathname === "/reservations.json") {
  event.respondWith(
    caches.open(CACHE_NAME).then(function(cache) {
      return fetch(event.request).then(function(networkResponse) {
        cache.put(event.request, networkResponse.clone());
        return networkResponse;
      }).catch(function() {
        return caches.match(event.request);
      });
    })
  );

The first condition returns the HTML of the My Account page using the cache, falling back to network pattern. The second serves reservations.json with the network, falling back to cache with frequent updates pattern (similar to how we cache and serve events.json).

By making these small changes, our accounts page app shell can now present a meaningful experience to the user within milliseconds. All of the hotel’s branding elements are immediately rendered, as well as most of the content that appears “above the fold.” The booking widget is available immediately and can be used without delay. Dynamic reservation and event data is only loaded and displayed once this initial shell has been rendered.

Achievement Unlocked

Yes, being able to render our app to offline users is awesome. But this chapter has dramatically improved the experience for connected users as well.

Setting aside the obvious improvement for offline users, it is easy to dismiss all that we have achieved in this chapter as a minor win. After all, we are viewing the site through an unrealistically fast connection to a local server.

Let’s gain some perspective.

Using Chrome’s developer tools, we can emulate different connection speeds (see “Developer Tools” for more details), and accurately measure the results.

Let’s see how our home page loads using a 3G connection:

Time until DOM ready Total loading time Bandwidth used

Without SW, 1st Visit

1,200 ms

13 sec

1.1 MB

Without SW, 2nd Visit

587 ms

4.9 sec

357 KB

With SW, 1st Visit

1,200 ms

13 sec

1.1 MB

With SW, 2nd Visit

155 ms

1.1 sec

29.7 KB

Both with and without a service worker, the home page loads in 13 seconds on the user’s first visit. The amount of bandwidth used is also similar. But once the user visited our site once, the difference on every consecutive visit is astounding:

  • The home page loads 4.5 times as fast with the service worker.

  • The DOM ready event fires 3.8 times as fast with the service worker.

  • The user has to download 91% less data, significantly reducing his costs, and our hosting costs.

Users navigating to the account page from the home page also see tremendous gains:

Time until DOM ready Total loading time Bandwidth used

Without service worker

578 ms

1 sec

28.9 KB

With service worker

150 ms

0.6 sec

14.9 KB

A gain of 428 ms in the time it takes the DOM to load fully may seem inconsequential. But taken in perspective, it is anything but. When a user clicks the My Account button, having the next screen load in 150 ms versus 578 ms is the difference between the feeling of navigating between web pages and the feeling of navigating between screens in a native app. In Chapter 6 we will improve this page further.

With just a few basic building blocks and a few common caching patterns, we were able to achieve extraordinary results in just a few lines of code. These results improve both the user’s experience and reduce the amount of bandwidth used (saving money for our users and reducing our costs).

Note

There is more to building an offline-first app than simply handling changes in connectivity.

As we improve support for offline functionality, new user experience challenges arise. For example:

  • How can we communicate to an offline user that content she is looking at is served from cache and may be stale?

  • How can we assure the user that changes she makes will not be lost even if she loses connectivity?

We will explore these UX challenges in more depth in Chapter 11.

Summary

This chapter gave us some great opportunities to look at a few common caching patterns and see how we can use each to solve the different challenges that arise on the road to offline-first bliss.

Hopefully, the different caching patterns outlined in this chapter can help guide you along the way. Just remember that they are not rigid blueprints. Sometimes you will need to mix and match them (e.g., like our event image code that caches on demand, but also implements a generic fallback pattern), and sometimes you will have to come up with something completely different (e.g., if a generic fallback image wasn’t acceptable to our clients, we might have considered parsing the events.json file during the install event and caching the images within it).

There is no preset formula to apply to your web app that will unlock an offline-first badge. When planning the strategy for your app, always consider how and when each resource is needed. Weigh the need for each resource to be up-to-date versus potential performance benefits.

Always consider the user’s behavior and needs first, and use that as a guide to finding the approach that would result in the best user experience.

1 These patterns, as well as a few others, were first categorized and named by Jake Archibald in “The Offline Cookbook”. It is highly recommended.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset