Skip to main content

Make your app work offline with Service Workers

10. Cache dynamic responses

Now we're going to further enhance our worker to cache the dynamic API response, learn about caching strategies and give our app full offline support. 

As it stands we're only caching static assets, such as image and JS libraries. Service Workers enable us to cache dynamic responses, such as API responses, but we need to put some thought into it first. And if we want to give our app offline support, we'll also need to cache the 'index.html' file, too.

We've got a few options to choose from for our caching strategy: 

  • Cache only
  • Network only
  • Network first, falling back to cache
  • Cache first, falling back to network
  • Cache then network

Each has its pros and cons. There is an excellent Google article in the Further Reading section that explains each approach and how to implement it.

The code we added above for the static assets uses the cache first, then falls back to the network approach. We can safely do this because our static assets are 'revved'. We need to decide what's best for our dynamic API response, though. 

The answer depends on the data returned by the server, how critical fresh data is to your users and how frequently you call the endpoint. If the data is likely to change frequently or if it is critical that is it up-to-date then we don't want to be serving stale data from our cache by default. However if you are going to be polling the endpoint every 10 seconds, say, then perhaps cache-first is more suitable and you can update the cache in the background in preparation for the next request. 

11. Consider user-specific responses

The other consideration with caching API responses is user-specific responses. If your app enables users to login then you need to remember that multiple users may use the same computer. You don't want to be serving a cached user profile to a different user!

In our scenario we can assume that the response from the API will be changing frequently. For a start it is responding with the forecast for the next 120 hours (five days), in three hour chunks, meaning if we call it again in three hours' time we will get a different response than we get now. And, of course, this is weather forecast data so at least here in the UK it will be changing all the time. For that reason let's go for network first, then fall back to cache. The user will get the cached response only if the network request fails, perhaps because they are offline. 

12. Cache the index

This is also a safe approach for our 'index.html' file so we'll include that in the cache, too. Remember you don't want to end up with users stuck on a stale version of your app (in their cache) because you've cached everything too aggressively. Another option here is to change the cache name, that is 'v1-assets' becomes 'v2-assets', with each new release but this approach has additional overhead because you need to add code to manually clean up the old caches. For the purposes of this tutorial we'll take the simpler option!

13. Add another fetch listener

Currently our existing fetch listener looks for a match for a request in all caches but it always follows the cache-first approach. We could modify this to switch modes but we'd end up with an unwieldy listener. Instead, just as you can with normal JS, we'll simply add another fetch listener. One will handle the assets cache and the other will handle the dynamic cache.

We need to include some of the same checks to filter out unwanted requests, then we want to allow certain requests to be cached. Add this new listener below your existing fetch listener:

/**
*
* DYNAMIC CACHING
*
*/
self.addEventListener('fetch', (event) => {
 // Ignore non-GET requests
 if (event.request.method !== 'GET') {
   return;
 }
 // Ignore browser-sync
 if (event.request.url.indexOf('browser-sync') > -1) {
   return;
 }
 let allow = false;

 // Allow index route to be cached
 if (event.request.url === (self.location.origin + '/')) {
   allow = true;
 }
 // Allow index.html to be cached
 if (event.request.url.endsWith('index.html')) {
   allow = true;
 }
 // Allow API requests to be cached
 if (event.request.url.startsWith('https://api.openweathermap.org')) {
   allow = true;
 }
 if (allow) {
   // Dynamic caching logic go here...
 }
});

14. Store responses in a dynamic cache

We're going to store these responses in a different cache, although this isn't strictly necessary. We'll call this one 'v1-dynamic'. Add this at the top of the 'sw.js' file:

    const dynamicCacheName = 'v1-dynamic';

We don't need to create this cache when the Worker installs because it only caches responses reactively – that is, after the browser has made the request. Instead we can do all the work in the fetch listener.

Let's add the network first logic inside our if (allow) statement.

 // Detect requests to API
 if (event.request.url.startsWith('https://api.openweathermap.org')) {
   // Network first
   event.respondWith(
     // Open the dynamic cache
     caches.open(dynamicCacheName).then((cache) => {
       // Make the request to the network
       return fetch(event.request)
         .then((response) => {
           // Cache the response
           cache.put(event.request, response.clone());
           // Return the original response
           return response;
         });
     })
   );
 } else {
   // ...

This code opens the cache, makes the network request, caches the network's response and then returns the response to the page. 

Open up the app. Reload the page to get the latest version of the Worker. Now click through until you see the result page meaning a request has been made to the API.

Once that has happened check in DevTools again and you should see the two caches and the cached API response and index route in the dynamic cache.

15. Tell the Worker what to do when offline

So we've got our cached response, but if we go offline again you'll see that the app still fails to load.

Why is this? Well, we've not told the Worker what to do when the network request fails. We can correct this by adding a catch method to the end of the fetch(event.request) promise chain.

  .catch(() => {
    // On failure look for a match in the cache
    return caches.match(event.request);
  });

Now save and try this again in offline mode. Hopefully you'll now see the app working as if it were online! Pretty cool.

16. Manage user expectations

Right, so we've got a fully functioning offline-capable app – but it isn't going to magically work all the time. The data we get from the API is time-sensitive so we could end up in a situation where the cached response is served up but it is out of date and none of the data is relevant. It's worth noting that cached data doesn't expire automatically – it has to be manually removed or replaced – so we can't set an expiry date like we can with a cookie.

The question our app asks is 'Will it rain today?', yet we get five days' worth of data in the API's response so, in theory, the cached version will be valid for five days even though the forecast will become less accurate as times goes by. 

We should consider these two scenarios to manage the user's expectations:

  • User is offline and has been served an old, almost out-of-date cache.
  • User is offline and the cached data is out-of-date.

We can detect the user's network status in the page but on a mobile, non-WiFi connection it's possible that connection was lost momentarily just as the API request was being made. So rather than displaying a 'You are offline' message for a brief flicker it would be better to determine that the response received by the page is from the cache rather than the network.

Fortunately, because our data already contains date/time information, we can determine if the data is from the cache by checking if the first date is in the past. If this wasn't the case we'd probably be able to modify the body of the response in the Worker before caching it to include a timestamp.

17. Flag up old data

Time to open up the app's 'main.js' file. On line 172 you'll see that we are already creating an array called inDateItems that filters the full array so that it only contains forecast items for today's date. Then below this we check if the array has any items.

If it is empty we show an error message to the user informing them that the data is out-of-date, so this already covers one of the scenarios. But what about when the data is old but not fully out of date?

We could do this by checking the date of the first item in the array and comparing it to now to see if it exceeds a certain threshold. You can add these constants just inside the inDateItems.length check:

    // ... 

    // Ensure we actually have relevant data points
    if (inDateItems.length) {
      // Define a threshold amount. This is one day in milliseconds
      const staleThreshold = 24 * 60 * 60 * 1000;
      // Check difference between first data point and now
      const dateDiff = Date.now() - inDateItems[0]._date;
      // Does the date exceed the threshold?
      const dataIsStale = dateDiff > staleThreshold;
      // ...

Now we have a Boolean to flag if our data is stale or not, but what should we do with it? Well, here's a little something we made earlier… add this below the lines you've just added:

    // Show the message if data is stale    
    if (dataIsStale) {
      showStale();
    }

This pre-prepared method will display a message to the user that tells them the data is stale – It's not easy to simulate stale data so call showStale() without the dataIsStale check to manually show the UI. In addition it provides a button which will allow them to refresh the data and a warning message if they are currently offline. When offline, the button is disabled. 

This is easily achieved by listening to the online and offline events that are emitted on the window, but we also need to check the initial state because the events are only emitted when the status changes. Our new Service Worker allows the page to be loaded even when there is no connection so we also can't assume we have a connection when the page renders. Check the code in 'main.js' to see how this is implemented.

18. Head to DevTools

Once a request has been made to the API, check in DevTools again and you should see the two caches, with the cached API response and index route in the dynamic cache

Once a request has been made to the API, check in DevTools again and you should see the two caches, with the cached API response and index route in the dynamic cache

Now this is when development starts to get tricky. We're making changes to files that are cached by our Service Worker, but because we're in dev mode the file names aren't revved. Changes to the Worker itself are automatically picked up and handled because we ticked the 'Update on reload' option in DevTools but the cached assets aren't reloaded because we're using a cache-first approach – meaning we don't get to see our changes to the app's code.

Once again DevTools comes to the rescue. Next to the 'Update on reload' option is an option called 'Bypass for network'. This slightly obscure name doesn't make it obvious (at least not to me!) what it actually does. But if you tick this option then all requests will come from the network, rather than the Service Worker. 

It doesn't disable the Worker entirely so you will still be able to install and activate the Worker but you can be sure that everything comes from the network.

19. Remove the stale cache

So we know we've got a stale response in the cache but how do we rectify this? In this scenario we don't really need to do anything because once the user has reconnected to the internet they can run the request again and the cache will be updated in the background – just one of the benefits of a network-first approach.

However, for the purposes of this tutorial, we wanted to demonstrate how you can clean up stale items in your cache.

As it stands the Worker is manipulating the cache but only the page is aware that the data is out of date. How can the page tell the Worker to update the cache? Well, we don't have to. The page can access the cache directly itself.

There is a click handler for the refresh data button ($btnStale) ready to be populated on line 395 (approx). 

Just as in the Worker, we need to open the cache using its name first. We named our API cache v1-dynamic so we have to use the same name here. Once open we can request that the cache deletes the item matching the request URL. Add the following inside the click handler to do the magic:

   // Reload the data
   $btnStale.on('click', () => {
     caches.open('v1-dynamic').then((cache) => {
       // Get the API url
       const url = getUrl(latLng);
       // Delete the cache
       cache.delete(url).then(() => {
         // Re-fetch the result
         fetchResult();
       });
     });
});

In production you'd need to check the browser has support for the cache API before implementing this.

20. Finish up

Done. In the first part of this tutorial, we reduced our subsequent load time from around 30 seconds to less than one second. Now we've made the app fully offline compatible. 

Hopefully, that'll give you a good grounding in how to set up a simple Service Worker and show you some of the things you need to be aware of. You can most definitely use this in production today. Good luck!

This article originally appeared in net magazine issues 311 and 312. Buy back issues or subscribe now.

Read more: