Blog

We believe there is something unique at every business that will ignite the fuse of innovation.

AndroidIntro

I started a project with the goal to prove to a co-worker (Android Developer) that a mobile web app could be really performant if you keep things simple and use some of the latest technologies that are available to JavaScript developers.

I had heard about Service Workers and always thought they sounded like a powerful JavaScript API. I thought this would be a good opportunity to learn how to employ Service Workers in a modern mobile web application. My experience implementing Service Workers went pretty smoothly, but I thought I’d share some gotchas and positives that I came across.

What is a Service Worker?

The Service Worker API is relatively young API that is quite powerful. I won’t go into great detail describing what a Service Worker is because the MDN Article on Serivce Workers does a much better job of explaining it. At a high level, it’s an API that allows scripts to run in the background, which act as a proxy between a web app and browser.

Although a Service Worker can do a lot of cool things, the biggest buzz is around their ability to provide offline capabilities. That’s exactly where I decided to start.

The App

I decided to create a movie app where I could search for movies and save them to a personal list that shows what movies I have seen. I used third party API to find movies, and Mongodb, hosted on mLab, to save my personal list of movies. I used NodeJS as my app server and hosted the app on Heroku. The rest is plain old JS, HTML, and CSS.

I chose not to use any JS frameworks because the app’s functionality was small and simple. Adding a framework without having a true need for it can add unnecessary bloat to your application and slow it down.

Implementing a Service Worker

Registering a Service Worker

The first thing I had to do was register my Service Worker. Something that might trip you up is not having Service Workers enabled in your browser. Chrome and Firefox support Service Workers. For me, Chrome had Service Workers enabled by default, but in Firefox I had to enable it. To do that I went to about:config and set dom.serviceWorkers.enabled to true.

Here is sample code for how to register a Service Worker:


if (‘serviceWorker’ in navigator) {
  navigator.serviceWorker.register(‘/serviceWorker.js’, { scope: ‘/’ }).then(function(reg) {
    console.log(‘REGISTERED!’)
  }).catch(function(error) {
    // registration failed
    console.log(‘FAILED TO REGISTER ‘ + error);
  });
}

Service Worker Scope

An important step when registering a Service Worker is making sure its scope is set properly. The scope of the Service Worker by default is set to the folder it is in and all subfolders in that directory. You can narrow the scope into subfolders, but cannot broaden the scope outside of the folder the Service Worker is in.

This is important because some app directory structures can cause problems like my structure did for me. I had all my JS files in a separate folder from my CSS and HTML. Here is an example:


Root/
  Index.html
  JS/
    Serviceworker.js
    app.js
  STYLES/
    Main.css

Since the Service Worker was in a JS folder, it only had access to the files and subfolders in that folder. That means I couldn’t access the other assets, like the HTML and CSS, which I needed for caching. Be sure that your Service Worker has everything it needs in its scope.


**Now the SW has the proper scope
Root/
  Index.html
  Serviceworker.js
  JS/
    app.js
  STYLES/
    Main.css

Service Worker SSL Requirements

If you have followed a tutorial or read the API spec on MDN, then you know that Service Workers need to be served over HTTPS. This isn’t entirely true though. I was happy to see that you do not need a HTTPS server when working locally.

You can create a local HTTPS server, which is the recommended approach in order to keep your local environment as close to QA and Production as possible. My project was never meant to be more than a test app to work with Service Workers, so locally I used a simple HTTP server. Also, in my case, I deployed my app to Heroku, which takes care of the HTTPS requirement for me.

Once I got my server up and running, and successfully registered my Service Worker, I saw net::ERR_FILE_EXISTS error in my console. This error is specific to Chrome and can be fixed by upgrading to version 50 or higher. If you aren’t able to upgrade your Chrome browser, the error will not stop you implementing a Service Worker.

Service Worker Install Event

After the configuration steps had been completed, it was time to actually start using the Service Worker. The first step is to setup the install event. The install event is triggered when a Service Worker is first installed. The code I have included in the install event opens a new cache with a name unique to my app, and adds an array of static assets to that cache.


this.addEventListener(‘install’, function(event) {
  event.waitUntil(
    caches.open(‘movieCacheV1’).then(function(cache) {
      return cache.addAll([
        ‘/’,  //Important to include the root
        ‘/index.html’,
        ‘/src/app.js’,
        ‘/src/movieRest.js’,
        ‘/src/styles/movieCheck.css’,
        ‘/src/styles/pure-min.css’
      ]);
    })
  );
});

It’s important to note here that the root is required when adding assets to the cache in the Install Event. If excluded, the code in the next section, Service Worker Fetch Event, will produce an error.

Service Worker Fetch Event

To retrieve the cached assets I needed to configure the fetch event. The fetch event is triggered when an HTTP request is made from the controlled app. Initially, all I wanted to do was check to see if the request was cached, and if it’s not, then go out to the web server. I read the documentation and I decided to implement the example that was provided.


this.addEventListener(‘fetch’, function(event) { 
  event.respondWith(
    caches.match(event.request).catch(function() {   
      return fetch(event.request); 
    })
  ); 
});

I encountered two problems here. First, I was seeing the following error in my browser console The FetchEvent for “http://localhost:8001/” resulted in a network error response: the promise was rejected. This was easily remedied by including the root into the list of assets as described in the previous section.

The second problem that I ran into was that even if the request wasn’t found in the cache, it still wasn’t hitting the catch(). I realized that although the request didn’t match anything in the cache, the promise was still a success. I changed the code to handle a successful promise.


this.addEventListener(‘fetch’, function(event) {
  event.respondWith(caches.match(event.request).then(function(response){
      if(response)
        return response;
      return fetch(event.request).then(function(response){
        return response;
      });
  }));
});

In the success callback, the code checks to see if the response is defined. If it is, that means there’s a match and the cached response is returned. If it’s not defined, then there isn’t a match and the request needs to go out to the web server.

Expanded Install and Fetch Event Functionality

Now that I have all my assets cached and can retrieve them with ease, the application can now run offline. The excitement wore off pretty quickly since loading a static html page doesn’t do much for me. I wanted to cache my personal list of movies. Luckily, this was as easy as adding the request URL to the list of assets.


this.addEventListener(‘install’, function(event) {
  event.waitUntil(
    caches.open(‘movieCacheV1’).then(function(cache) {
      return cache.addAll([
        ‘/’,
        ‘/index.html’,
        ‘/src/app.js’,
        ‘/src/movieRest.js’,
        ‘/src/styles/movieCheck.css’,
        ‘/src/styles/pure-min.css’,
        ‘/mymovies’ //Now I can easily cache my list of movies!
      ]);
    })
  );
});

This was simple because I used a webserver like NodeJS to handle data requests to different hosts. Making a cross-domain request is still possible here, but it involves a little more work. You’d have to create a new request and set the request.mode to cors or no-cors. The Install Event API documentation has a good example of this.

The app has become more useful, but I noticed a major issue with the current cache strategy. If the app is always pulling my movie list from cache, then I won't see the new movies I have been adding to the list. I needed a new cache strategy and came up with two different solutions. Each solution still has its issues in terms of managing the cache, but the goal was to play around with Service Workers, not lecture on the best ways to cache data.


this.addEventListener(‘fetch’, function(event) {
  console.log(‘METHOD: ‘+ event.request.method +’ and URL: ‘+ event.request.url);
  //Check if request is a POST
  if(event.request.method === ‘POST’){
    event.respondWith(
      //POST the new movie to my list of movies
      fetch(event.request).then(function(response){
        //in the success callback make another request for my list of movies
        fetch(‘/mymovies’).then(function(resp){
          //open the cache with same name defined in the install event
          caches.open(‘movieCacheV3’).then(function(cache){
            //cache the movie list
            cache.put(‘/mymovies’, resp);
          });
        });
        return response;
      })
    );
  }
  //if it’s not a POST use different caching method
  else{
    event.respondWith(
      caches.match(event.request).then(function(response){
        if(response)
          return response;
        return fetch(event.request).then(function(response){
          return response;
        });
     }));
  }
});

The first solution was to modify the Service Worker so that it will hijack the POST request that adds a new movie. The code checks to see if the event request is a POST and then, instead of looking for a cache match it, just makes a POST fetch request (fetch API does both GET and POST). If the request is successful then the code executes another fetch request, but this time it’s a GET request for my list of movies. When the list of movies returns, the response is then cached. This solution is fine if the user is only creating movies from one client.

In the second solution I decided to explore the Cache API and see what all it can do. Something to note is that not everything you see in the code examples is specific to the Service Worker API. For example, Fetch and Cache are both stand alone APIs that can run outside of the Service Worker scope.

Instead of always pulling my list of movies from cache, I wanted to initially grab what was in cache, but still make a request to the server for the most current list. This method provides the user with data quickly, regardless of the network connection or lack thereof. Once the data comes back from the server the UI is updated with the most current list of movies.


function _httpGetWrapper(url, success, fail){
  if(url === ‘/mymovies’){
    //check cache for mymovie request
    caches.match(url).then(function(response){
      if(response){
        return response.json();
      }
    }).then(function(data){
      console.log(‘#### Cache Success’);
      //updated DOM with success callback
      success(data);
    });
  }
  fetch(url).then(function(response){
    console.log(‘#### Network Success’);
    //updated DOM with success callback
    success(response);
  });
}

I created an http get wrapper that takes a URL and two callback functions. The only data requests that are cached in my app are the /mymovies requests so the app only checks the cache if it’s that URL. In this example it checks the cache for /mymovies and if it finds a match it will return the response and update the DOM. At the same time it triggers a network request. When the network request returns, the DOM is updated with the latest list of movies. A full list update can be an undesirable experience for the user. To limit the disruption to the user experience, you can check to see if there is a difference in the data returned from cache and network responses and only update the DOM by appending the missing items to the list.

The Service Worker had to be modified slightly as well. A cache.match() check is already done for /mymovies before a network request is made so I don’t want to do that again in the Service Worker Fetch Event.


this.addEventListener(‘fetch’, function(event) {
  var urlToMatch = event.request.url.substring(event.request.url.length - 6);
  if(urlToMatch === ‘mymovies’){
    event.respondWith(
      //make network request for my movies
      fetch(event.request).then(function(response){
        //open the cache
        caches.open(‘movieCacheV3’).then(function(cache){
          //add response to cache
          cache.put(event.request, response);
        });
        //clone and return the response
        return response.clone();
      },
      function(error){
        return error;
      })
    );
  }
  else{
    event.respondWith(
      caches.match(event.request).then(function(response){
        if(response)
          return response;
        return fetch(event.request).then(function(response){
          //console.log(‘RETURNING FETCHED RESPONSE’)
          return response;
        });
      }));
  }
});

The code first checks to see if the request is for /mymovies and, if so, lets the request pass through to the network. The response is then cached so that the cache can stay as up to date as possible. The clone() method is needed since we are using the Body of the response twice. The cloned response is sent back to the app for processing.

Updating a Service Worker

The final piece to this app was to update the Service Worker. This was as simple as incrementing the version number from movieCacheV1 to movieCacheV2. The docs for the API say that the old Service Worker will handle all fetch requests until any tabs or pages using the old Service Worker are closed. I found this was very inconsistent. I’d update the version, see it successfully install, but the fetch requests were a mixture of the old Service Worker and the new. This was only happening when using my mobile chrome browser, which is the only mobile browser I used while testing. I recommend deleting the old cache if you are able to. That was the only way I was able to get consistent results.

Conclusion

Overall, my first experience with Service Workers was great! I am barely scratching the service of their full capabilities, but I was able to quickly provide my app with offline capability and significantly improved the load time.

The app I have created is small, but by implementing a Service Worker my app consistently loads in under a second. It doesn’t matter if the network is slow or non-existent, the app loads quickly. The same app without a Service Worker loads in about 3 seconds on WiFi and anywhere between 3 to 5 seconds on 4G and 3G networks.

I am not saying web apps are ready to replace native apps, but with tools like Service Workers the gap in performance is shrinking.

About the Author

Matt KersterMatt Kerster is software developer with 10+ years in IT Consulting. He has worked with a variety of technologies, but his passion is in mobile and web technologies. When not experimenting with the a new JS library or API, he likes to relax with his wife and son, and play ice hockey on the weekends.

About the Author

Matt Kerster
Matt Kerster is a Manager in Reston VA with 10+ years in IT Consulting. He has worked with a variety of technologies, but his passion is in mobile and web technologies.