Using Javascript service worker for offline/cache strategy

frontend8 Min to Read04 Aug 17

Do you want your user to browse the content when they go offline? Do you want to have more control over caching strategy on browser side? Then service worker is the thing, you should know and care about. Depending on caching strategy, it also enables the user to save money on data. There are other features like push notification, background sync etc.. that service worker can do and will have more capability in future. But in this article, our scope will be limited to understanding how service worker works and how to implement an offline first caching strategy.

About SW

Service worker is a Javascript code that runs in the background, just like Webworker but unlike Webworker, it lives even if the user closes the page tab. Service worker receives some special browser event like install, fetch, activate, etc., that make it a legitimate candidate to use for caching management. Our code in service worker can receive and handle those events to implement desired caching strategy. Service worker code is implemented in a dedicated and independent javascript file.

As of now, it is supported by Chrome, Firefox, and Opera while it is in development for Safari and Edge. It only works on https site with exception to localhost for local development.

Please note, most of the service worker API is promise based, so you will see lots of code related to promise. Since our motto is to understand service worker, we will not be touching on promise related code explanation.

Regestering SW

The first step to use service worker is to register it with the browser. The main API that we use for registration is navigator.serviceWorker.register. This API takes ‘location of SW file’ as an argument. See below code for an example.

function istallServiceWorker() {
  console.log("Insalling service worker")
  //First check if browser support service worker
  if ("serviceWorker" in navigator) {
    navigator.serviceWorker.register("/service_worker.js").then(
      registration => {
        console.log(
          "ServiceWorker registration successful with scope: ",
          registration.scope
        )
      },
      err => {
        console.log("ServiceWorker registration failed: ", err)
      }
    )
  }
}

istallServiceWorker()

We know that service worker might not be supported by all the browsers, So we should first check if targetted browser support 'SW' before registering. This is what we are doing first in above code by checking if serviceWorker exist as properties on navigator object. As said, We are passing SW file location /service_worker.js inside register() function. We are also printing the registration status as either failed or successful.

So now we know, how to register service worker, But where exactly we should put this code? Should we put it in all the page or just home page?

Ideally, we want the browser to register our SW from any page, user visit. For example, suppose we have put this code only in homepage and one user visited page other than the homepage, then the browser will not register our service worker, hence user will not able to use our offline feature. So this code should run from any page. To do this, we can either directly inject this code in all HTML page or load through separate external script file in all HTML page.

Another important thing to know is the location of actual SW code file not the location of SW registration code. This determines the scope of the service worker and scope determines for which all page or resources, SW will receive fetch event. i.e Suppose the location of SW is /image/service_worker.js, if browser needed resource /video/video1.mp4, then your SW will not receive fetch event for this resource, hence you will not able to control caching of such resources.

Reason being, in this case, SW will receive fetch event only for resource like /image/image1.png or /image/jpeg/image2.jpg or any sub path pattern under /image/**/**. If you want to control all pages from domain, then simply change the location of SW to root of domain i.e /service_worker.js

Summary: service worker should be available for registration on all the pages. Location of actual service worker code file is very important because it determines the scope of SW and scope determines which all page or resources, service worker can control. It is recommended to serve SW from the root of the domain.

Now, if we want to cache some important resources at the time of installation of service worker, then we have to handle install event as shown below.

//service_worker.js
var CACHE_NAME = "sysleaf_v1"
var PRE_CACHE_URL = ["script.js", "style.css"]

self.addEventListener("install", evt => {
  evt.waitUntil(precache())
})

function precache() {
  return caches.open(CACHE_NAME).then(function(cache) {
    return cache.addAll(PRE_CACHE_URL)
  })
}

In above code, PRE_CACHE_URL is the list of URL or resources that we want to put in the cache. Since we can have multiple caches, we need to give it a name i.e sysleaf_v1 as an identifier. Inprecache() function, we are opening the cache then putting all the resources that need to be cached. Note that precache function need to be wrapped inside evt.waitUntil(), otherwise service worker will go in the idle state before precaching urls. We have to keep our service worker active until we finish precahcing all the PRE_CACHE_URL.

As i said, we can have many caches, depending on our use case. For example, we can create three cache namely sysleaf_asset_v1, sysleaf_post_v1, sysleaf_vendor_v1. All the asset file like css and script will go to sysleafassetv1, all the vendor related resources go to sysleafvendorv1 and so on. In above code caches is inbuilt object, that represent all the caches('sysleafassetv1', 'sysleafpostv1', etc.).

From cache or network.

In this section, we will see how SW handle browser's resource request. Whenever Browser needs any resource, it triggers fetch event. In our service worker, we can capture this fetch event and check if asked resource is in the cache, or directly fetch from the network and respond appropriately to the browser request.

self.addEventListener('fetch', (evt) => {
evt.respondWith(fromCache(evt));
}

function fromCache(evt) {
var request = evt.request;
return caches.match(request)
.then(function(matching){
if(matching){
return matching
}else{
Promise.reject('no-match');
}
})
}
self.addEventListener("fetch", evt => {
  evt.respondWith(fromNetwork(evt))
})

function fromNetwork(evt) {
  var request = evt.request
  return fetch(request)
}

To respond to browser request, we use API evt.respondWith('asked resource') inside fetch event. This API expect an promise(for resource) in its argument.

See how we are finding the match for browser request in cache using api caches.match('browser request'). This API returns the matched resources if found in cache, otherwise return undefined.

Please note, In above 'Fetch From Cache' code, we are looking for resource matches in all the caches, not in specific cache. We can also look in specific cache by opening the reference to particular cache using caches.open(CACHE_NAME) then matching in it.

'Fetch From Network' code is quite simple. We first fetch the response from network using fetch('request') API then return the fetch's response back to browser.

Update SW and Delete Old Cache

So what if we make any changes to our service worker code after the first installation. How do we tell the browser about our updated service worker? No Worry!, Browser automatically installs the service worker, even if there is a change in the single character of SW code. It always checks for SW code changes, if no changes, browser simply ignores. So updating the service worker code is just changing the cache name version. i.e from sysleaf_v1 to sysleaf_v2

So how about deleting old cache, i.e Suppose, we have made some critical changes in our resources that were earlier cached under sysleaf_v1 and now we want to make sure that all the resources under this cache get deleted. So to do this, first, we have to rename our cache name to something else i.e sysleaf_v2 so that our new resources get installed under this new cache. So now, we have all the updated resources under sysleafv2, but old resources under sysleafv1 are still lying there in the browser. To delete this, we need to capture activate event and put our logic there.

//var CACHE_NAME = "sysleaf_v1";
var CACHE_NAME = "sysleaf_v2"

self.addEventListener("activate", evt => {
  evt.waitUntil(delOldCache())
})

function delOldCache() {
  var deletedCaches = []
  caches.keys().then(function(allCacheNames) {
    allCacheNames.forEach(cacheName => {
      if (cacheName !== CACHE_NAME) {
        deletedCaches.push(caches.delete(cacheName))
      }
    })
  })
  return Promise.all(deletedCaches)
}

If we earlier had cache 'sysleafv1' and now we are installing 'sysleafv2', then we have total two caches. So in above code, caches.keys() will return sysleafv1 and sysleafv2. if any of this is not equal to current cache name, then it will be deleted using API caches.delete(cacheName). So in this particular case, sysleafv1 will be deleted because it is not equal to sysleafv2.

Caching strategy

In this section, we will try to implement one of the caching strategies that we can call offline-first. This basically means, try to respond from cache first then network. After fetching from the network, update the cache, so that next time, it could be fetched from cache. See below pseudo code for more explanation.

if resource in CACHE then
  return the response to browser
else
  fetch the response from NETWORK
  if network response is 200 then
    cache the response
  return the response to browser
var CACHE_NAME = "sysleaf_v2"

self.addEventListener("fetch", evt => {
  evt.respondWith(fromCacheOrNetwork(evt))
})

function fromCacheOrNetwork(evt) {
  var request = evt.request
  caches.match(request).then(function(matching) {
    if (matching) return matching
    else {
      var netRequest = evt.request.clone()
      fetch(netRequest).then(function(response) {
        if (response.status === 200) {
          var netResponse = response.clone()
          caches.open(CACHE_NAME).then(function(cache) {
            cache.put(request, netResponse)
            return response
          })
        } else {
          return response
        }
      })
    }
  })
}

The new thing in above code is cloning of request and response, others are already explained in an earlier section. Response is a stream which can be consumed only once. So if we consume the network response to save it in a cache, then we can not use the same response to respond to the browser and if we consume the network response to respond to the browser, then we can not use the same response to save it in a cache. So this is the reason, we clone the response.

Reference

If you loved this post, Please share it on social media.