Let's Take This Offline

Let's Take This Offline

Written by Carmen Bourlon and edited by Lovisa Svallingson

What is Offline First?

Offline First is a practice which considers what happens when the user loses connection. Many apps do not take into account what will happen when the user loses connection. But, whether we like it or not, connection loss is inevitable.

In day-to-day life something very simple can result in losing connection. Maybe your router goes out unexpectedly and you have to wait until Monday for a new one. Perhaps your user's Internet Service Provider is being overloaded, or the cloud provider for your app has unexpected outages.

Mobile networks frequently experience changes in coverage from one location to the next. Given that half of internet traffic is coming from mobile devices, having an app that keeps working during subway rides and remote mountain trips is very beneficial to your user.

However, a lack of consistent internet is more than a fleeting annoyance. One in five adults in the United States don't have consistent internet access beyond their smartphone. One barrier for adults without internet access is cost. In a study in Illinois, many adults cited cost as the reason they forego internet.

What's more, developing countries also often experience issues with intermittent internet connection, and a lack of internet access can be a barrier to further development. As developers, we can help bridge the divide between online and offline.

Cache static assets with Service Workers

Note: You can view a service worker demo here.

We can use Service Workers to cache our static html pages and other assets in case our user loses their internet connection. Service Workers exist within the navigator object, which is built into JavaScript. This is beneficial because there's no extra overhead from an external library or framework. The navigator object includes other information about the user's state such as geolocation, language, and connection.

Note: Be sure to check the compatibility table for Service Workers as they aren't widely adopted just yet.

Setting up your first service worker is relatively simple, but there's a few things you need to know about the file hierarchy first. Typically you'll want to keep your Service Worker logic in a separate file from your application's JavaScript. A simple application might look like this:

- Application_Root
    -- index.html
    -- index.js
    -- service-worker.js
    -- img_dir
      -- my-image-name.jpg

First we'll register the service worker in our application's JavaScript file. We will place this function on the global scope of our application's index.js file.

    function registerServiceWorker() {
      if(navigator.serviceWorker) {
        navigator.serviceWorker.register('service-worker.js');
      }
    }
    
    window.addEventListener('load', registerServiceWorker);

It's important to wrap our Service Worker logic in an if-statement because Service Workers are not available in all browsers. This simple check will keep the app from running into errors if it's run in non-compatible browsers.

Notice that the Service Worker isn't being registered until after the page is loaded. Attaching registerServiceWorker to an event gives you more control over when the Service Worker will be registered. While the best time to register a Service Worker may vary based on the app, most of the intensive work of bringing in assets has been completed by the time the load event is fired. Registering your Service Worker based on the load event will give your user a better experience because the browser will have more resources available to handle registration.

Service Workers expose a number of events. One of the events we'll hook into is the oninstall event. We'll include the code below in our service-worker.js file. This will install our Service Worker file (which is contained separately from the application's javascript) into the user's browser.

    self.oninstall = function() {
      caches.open('cash').then(function(cache) {
        cache.addAll([
          'index.html',
          '/img_dir/my-image-name.jpg',
          'index.js'
        ]);
      });
    };

The function above is creating a unique cache for our files. Then the cache.open promise function adds specified files and assets to the cache we've defined. If we wanted to add some error handling, we could add a .catch function, exactly the same way you would with any other promise.

Now that we have a way to cache our files, it would be neat if we had a way to use the cache. The last part of our service worker will utilize the fetch event. This function will also live in the service-worker.js file.

    self.onfetch = function(event) {
      event.respondWith(caches.match(event.request));
    };

This event handler will fire whenever a fetch event occurs. Each time the page is loaded in, the Service Worker will check to see if it has cached assets for the given request. If it has seen the request before, the Service Worker will serve the cached assets, and if not, it will make a network request to fetch the assets. The cached assets are saved in a stack -- the oldest are at the bottom with the newest on top -- which allows the service worker to grab the newest cached assets it can find.

Now that we're using service workers to cache our assets, let's talk about what happens to requests when our user loses connection.

Check for connection with navigator.onLine

Service workers will only cache static files and assets, thus giving the appearance of your app. However on their own they won't help with handling network requests or notifying the user that connection has been lost.

With that in mind, one of the kindest things you can do for your user is continuously check for internet connection. One of the easiest ways to check for internet connection is by using navigator.onLine. The underlying idea of navigator.onLine is very simple -- it'll return a true value if you have a network connection, and a false value otherwise.

We can easily set-up a continuous check with the code below.

    window.addEventListener('offline', function(event) {
      console.log("We are offline! :(");
      // pause network requests
    });
    
    window.addEventListener('online', function(event) {
      console.log("We are online! :)");
      // restart network requests
    });

Note: Some browsers will return true even if the user is only connected to a local network. You can check browser compatibility here or find an additional solution here.

Handle requests with IndexedDB

At this point we are using Service Workers to cache pages. This way users aren't left in the dark if they temporarily lose connection. We're also continuously checking for internet connection, putting our app in the driver's seat to handle what happens next. But ... what should we do once we've determined we have lost connection?

There are multiple ways to approach what your app will do next after a connection loss. You could show an error message or modal to let the user know that they've lost connection and can't save work right now. You could also take advantage of a built-in option like localStorage to keep track of changes and actions that took place while the user was offline. However we could go even further with IndexedDB.

Let's take a look at implementing an IndexedDB solution for caching network requests. In our Service Worker example, we focused on caching static files and assets. In this example, we'll look at how we can use IndexedDB to cache any POST requests our user might make while they are offline.

Note: You can see a working demo of the example below here.

Our example will cache a form that accepts a name and email address. In order to track requests, we'll also note the time we cached the request. This will be important if our user has made multiple requests while they were offline. Once the user gets back online, we’ll submit the requests with the saved contents.

    var myDB = window.indexedDB.open("testEmailDB", 1);
    
    myDB.onupgradeneeded = function(event) {
      var db = event.target.result;
      var myObjStore = db.createObjectStore("emailObjStore", {autoIncrement: true});
    
      myObjStore.createIndex("name", "name", {unique: false});
      myObjStore.createIndex("email", "email", {unique: true});
      myObjStore.createIndex("dateAdded", "dateAdded", {unique: true});
    }

Note: For the purposes of this example, myDB is a global variable we will continually reference. You may decide you'd rather open and close connections as you need them. You can read more about that here.

So if we step through what's going on above, you'll notice that the first thing we do is open the database. We pass in two parameters -- the name of the database and the version number of that database. Schema additions and updates can only be run from the onupgradeneeded event. This event is fired in two instances: when a database is created for the first time and when a new version is needed -- typically new versions are limited to schema changes.

The next thing we'll do is add some data to the database.

    function addDataToDatabase() {
      var tmpObj = {
        name: name
        email: email,
        dateAdded: new Date()
      };
    
      var objStore = myDB.transaction('emailObjStore', 'readwrite').objectStore('emailObjStore');
      objStore.add(tmpObj);
    }

The first thing we did above was build an object that matched the database's schema. Next we accessed our object store and made sure we had read/write privileges and then added tmpObj to our database.

So now we know how to save data if the user loses connection, let's discuss how we'll retrieve the data once the user is back online.

    function retrieveDataFromDatabase() {
      var objStore = myDB.transaction("emailObjStore").objectStore("emailObjStore").getAll().onsuccess = function(event) {
          // now we can access the data at `event.target.result`
          // if we are planning on rebuilding and resending an ajax request, we would likely call that function here
      };
    }

Just as before, we open the transaction and then specify the object store we're targeting. Next we'll use getAll to fetch all records in that specific object store.

Once you have all the records, you could filter based on the time they were added to the database. This might be helpful if your user made multiple attempts at sending through a form, but you only want to send the most recent attempt.

After you've finished rebuilding your data object and sending the belated request, you may consider clearing old data from your database. This is beneficial for multiple reasons. It will help keep your database from being too cluttered, as well as limit the number of records your code will need to filter through next time a connection loss occurs.

We can clear records from a database like this.

    var clearRecords = myDB.transaction("emailObjStore", 'readwrite').objectStore("emailObjStore").clear().onsuccess = function(event) {
        console.log("clearing complete!");
    };

We establish which object store we are interested in, and we also specify that this will be a readwrite transaction. Transactions are readonly by default, so if we don't specify which type of transaction we want, we'll get an error.

Next we use the clear function to clear all records from the object store. Alternatively you could use delete and individually specify which records to delete by key.

And now we've successfully created a caching solution with IndexedDB!

What next?

Now that you know how to get started integrating Offline First techniques into your app, you'll be able to write faster and more user-friendly apps. Any or all of these techniques are a great start to a more empathetic user experience.

For more information about Offline First and the community, check out Offline Camp.

This is Fine: How I Learned to Love the Dumpster Fire

This is Fine: How I Learned to Love the Dumpster Fire

IRL Offline T-Rex Game powered by JavaScript

IRL Offline T-Rex Game powered by JavaScript