Background Sync with Service Workers

By  on  

Service workers have been having a moment. In March 2018, iOS Safari began including service workers -- so all major browsers at this point support offline options. And this is more important than ever -- 20% of adults in the United States are without Internet at home, leaving these individuals relying solely on a cellphone to access most information. This can include something as simple as checking a bank balance or something as tedious as searching for a job, or even researching illnesses.

Offline-supported applications are a necessity, and including a service worker is a great start. However, service workers alone will only get someone part of the way to a truly seamless online-to-offline experience. Caching assets is great, but without an internet connection you still can't access new data or send any requests.

The Request Lifecycle

Currently a request might look like this:

The Request Lifecycle

A user pushes a button and a request is fired off to a server somewhere. If there is internet, everything should go off without a hitch. If there is not internet ... well things aren't so simple. The request won't be sent, and perhaps the user realizes their request never made it through, or perhaps they are unaware. Fortunately, there's a better way.

Enter: background sync.

Background Sync

The Background Sync Lifecycle

The lifecycle with background sync is slightly different. First a user makes a request, but instead of the request being attempted immediately, the service worker steps in. The service worker will check if the user has internet access -- if they do, great. The request will be sent. If not, the service worker will wait until the user does have internet and at that point send the request, after it fetches data out of IndexedDB. Best of all, background sync will go ahead and send the request even if the user has navigated away from the original page.

Background Sync Support

While background sync is fully supported only in Chrome, Firefox and Edge are currently working on implementing it. Fortunately with the use of feature detection and onLine and offLine events, we can safely use background sync in any application while also including a fallback.

A simple newsletter signup app

(If you'd like to follow along with the demo, the code can be found here and the demo itself is found here.)

Let's assume we have a very simple newsletter signup form. We want the user to be able to signup for our newsletter whether or not they currently have internet access. Let's start with implementing background sync.

(This tutorial assumes you are familiar with service workers. If you are not, this is a good place to start. If you're unfamiliar with IndexedDB, I recommend starting here.)

When you are first setting up a service worker, you'll have to register it from your application's JavaScript file. That might look like this:

if(navigator.serviceWorker) {
      navigator.serviceWorker.register('serviceworker.js');
}

Notice that we are using feature detection even when registering the service worker. There's almost no downside to using feature detection and it'll stop errors from cropping up in older browsers like Internet Explorer 11 when the service worker isn't available. Overall, it's a good habit to keep up even if it isn't always necessary.

When we set up background sync, our register function changes and may look something like this:

if(navigator.serviceWorker) {
        navigator.serviceWorker.register('./serviceworker.js')
        .then(function() {
            return navigator.serviceWorker.ready
        })
        .then(function(registration) {
            document.getElementById('submitForm').addEventListener('click', (event) => {
                registration.sync.register('example-sync')
                .catch(function(err) {
                    return err;
                })
            })
        })
        .catch( /.../ )
    }

This is a lot more code, but we'll break it down one line at a time.

First we are registering the service worker like before, but now we're taking advantage of the fact that the register function returns a promise. The next piece you see is navigator.serviceWorker.ready. This is a read-only property of a service worker that essentially just lets you know if the service worker is ready or not. This property provides a way for us to delay execution of the following functions until the service worker is actually ready.

Next we have a reference to the service worker's registration. We'll put an event listener on our submit button, and at that point register a sync event and pass in a string. That string will be used over on the service worker side later on.

Let's re-write this real quick to include feature detection, since we know background sync doesn't yet have wide support.

if(navigator.serviceWorker) {
        navigator.serviceWorker.register('./serviceworker.js')
        .then(function() {
            return navigator.serviceWorker.ready
        })
        .then(function(registration) {
            document.getElementById('submitForm').addEventListener('click', (event) => {
                if(registration.sync) {
                    registration.sync.register('example-sync')
                    .catch(function(err) {
                        return err;
                    })
                }
            })
        })
    }

Now let's take a look at the service worker side.

self.onsync = function(event) {
    if(event.tag == 'example-sync') {
        event.waitUntil(sendToServer());
    }
}

We attach a function to onsync, the event listener for background sync. We want to watch for the string we passed into the register function back in the application's JavaScript. We're watching for that string using event.tag.

We're also using event.waitUntil. Because a service worker isn't continually running -- it "wakes up" to do a task and then "goes back to sleep" -- we want to use event.waitUntil to keep the service worker active. This function accepts a function parameter. The function we pass in will return a promise, and event.waitUntil will keep the service worker "awake" until that function resolves. If we didn't use event.waitUntil the request might never make it to the server because the service worker would run the onsync function and then immediately go back to sleep.

Looking at the code above, you'll notice we don't have to do anything to check on the status of the user's internet connection or send the request again if the first attempt fails. Background sync is handling all of that for us. Let's take a look at how we access the data in the service worker.

Because a service worker is isolated in its own worker, we won't be able to access any data directly from the DOM. We'll rely on IndexedDB to get the data and then send the data onward to the server.

IndexedDB utilizes callbacks while a service worker is promise-based, so we'll have to account for that in our function. (There are wrappers around IndexedDB that make this process a little simpler. I recommend checking out IDB or money-clip.)

Here is what our function might look like:

return new Promise(function(resolve, reject) {
    var db = indexedDB.open('newsletterSignup');
    db.onsuccess = function(event) {
        this.result.transaction("newsletterObjStore").objectStore("newsletterObjStore").getAll().onsuccess = function(event) {
            resolve(event.target.result);
        }
    }
    db.onerror = function(err) {
        reject(err);
    }
});

Walking through it, we're returning a promise, and we'll use the resolve and reject parameters to make this function more promise-based to keep everything in line with the service worker.

We'll open a database and we'll use the getAll method to pull all the data from the specified object store. Once that is success, we'll resolve the function with the data. If we have an error, we'll reject. This keeps our error handling working the same way as all of the other promises and makes sure we have the data before we send it off to the server.

After we get the data, we just make a fetch request the way we normally would.

fetch('https://www.mocky.io/v2/5c0452da3300005100d01d1f', {
    method: 'POST',
    body: JSON.stringify(response),
    headers:{
        'Content-Type': 'application/json'
    }
})

Of course all of this will only run if the user has Internet access. If the user does not have Internet access, the service worker will wait until the connection has returned. If, once the connection returns, the fetch request fails, the service worker will attempt a maximum of three times before it stops trying to send the request for good.

Now that we've set up background sync, we are ready to set up our fallback for browsers that don't support background sync.

Support for legacy browsers

Unfortunately, service workers aren't supported in legacy browsers and the background sync feature is only supported in Chrome as of now. In this post we'll focus on utilizing other offline features in order to mimic background sync and offer a similar experience.

Online and Offline Events

We'll start with online and offline events. Our code to register the service work last time looked like this:

if(navigator.serviceWorker) {
    navigator.serviceWorker.register('./serviceworker.js')
    .then(function() {
        return navigator.serviceWorker.ready
    })
    .then(function(registration) {
        document.getElementById('submitForm').addEventListener('click', (event) => {
            event.preventDefault();
            saveData().then(function() {
                if(registration.sync) {
                    registration.sync.register('example-sync')
                    .catch(function(err) {
                        return err;
                    })
                }
            });
        })
    })
}

Let's do a quick recap of this code. After we register the service worker, we use the promise returned from navigator.serviceWorker.ready to ensure that the service worker is in fact ready to go. Once the service worker is ready to go, we'll attach an event listener to the submit button and immediately save the data into IndexedDB. Lucky for us IndexedDB is supported in effectively all browsers, so we can pretty well rely on it.

After we've saved the data, we use feature detection to make sure we can use background sync. Let's go ahead and add our fallback plan in the else.

if(registration.sync) {
    registration.sync.register('example-sync')
    .catch(function(err) {
        return err;
    })
} else {
    if(navigator.onLine) {
        sendData();
    } else {
        alert("You are offline! When your internet returns, we'll finish up your request.");
    }
}

Additional support

We're using navigator.onLine to check the user's internet connection. If they have a connection, this will return true. If they have an internet connection, we'll go ahead and send off the data. Otherwise, we'll pop up an alert letting the user know that their data hasn't been sent.

Let's add a couple of events to watch the internet connection. First we'll add an event to watch the connection going offline.

window.addEventListener('offline', function() {
    alert('You have lost internet access!');
});

If the user loses their internet connection, they'll see an alert. Next we'll add an event listener for watching for the user to come back online.

window.addEventListener('online', function() {
    if(!navigator.serviceWorker && !window.SyncManager) {
        fetchData().then(function(response) {
            if(response.length > 0) {
                return sendData();
            }
        });
    }
});

Once the user's internet connection returns, we'll do a quick check if a service worker is available and also sync being available. We want to check on this because if the browser has sync available, we don't need to rely on our fallback because it would result in two fetches. However, if we do use our fallback, we first pull the data out of IndexedDB like so:

var myDB = window.indexedDB.open('newsletterSignup');

myDB.onsuccess = function(event) {
    this.result.transaction("newsletterObjStore").objectStore("newsletterObjStore").getAll().onsuccess = function(event) {
        return event.target.result;
    };
};

myDB.onerror = function(err) {
    reject(err);
}

Next we'll verify that the response from IndexedDB actually has data, and if it does we'll send it to our server.

This fallback won't entirely replace background sync for a few reasons. Firstly, we are checking for online and offline events, which we do not need to do with background sync because background sync handles all that for us. Additionally, background sync will continue attempting to send requests even if the user has navigated away from the page.

Our solution won't be able to send the request even if the user navigates away, but we can pre-emptively check IndexedDB as soon as the page loads and send any cached data immediately. This solution also watches for any network connection changes, and sends cached data as soon as the connection returns.

Next steps in offline support

Edge and Firefox browsers are currently working on implementing background sync, which is fantastic. It is one of the best features for providing a more empathetic experience for users moving between internet connection and connection loss. Fortunately with a little help from online and offline events and IndexedDB, we can start providing a better experience for users today.

If you'd like to learn more about offline techniques, check out my blog: carmalou.com or follow me on Twitter.

Carmen Bourlon

About Carmen Bourlon

Carmen is a software developer living and working in Oklahoma City, with a special interest in offline technology. She has spoken at multiple conferences about Offline First, and organizes a local women's technology group. In her spare time, Carmen defends Hyrule from the evil Ganon and writes Twitter-bots.

Recent Features

Incredible Demos

Discussion

  1. Nice post!

    I have a few remarks:
    * Using background-sync doesn’t require using IndexedDB, we can use Cache API as well to store data in the main thread so we can retrieve it in the worker.
    * Why not using async/await instead of .then? It makes the code easier to read :)
    * I’m not sure that background-sync check for connectivity change ; as far as I have tested, it was just a convenient back-off mechanism
    * On Chrome, there will be three attempt when registering for a tag. The first, right when receiving the sync event. If event.waitUntil receive a rejected promise another attempt will be made 5 minutes later. If the second attempt still fails, a last attempt will be scheduled 15 minutes later. If the third attempt fails the tag is removed from the registry. So, if you still want to try sending your data you will have to register again for your tag.
    * I’m not particularly found of navigator.onLine since it reflect the presence of working network adapter but not network connectivity. For example, if you are connected to a hotel Wifi but don’t have access to internet, navigator.onLine will still return true. Besides, people are tempted to use this boolean to change the behavior of their application (“if the user is online display the button, otherwise hide it”). I think this is a really bad practice because you end up building two applications while trying to fetch and displaying an error message is way easier.

    Speaking of fallback, I tried to mimic the background-sync API for browsers not supporting it. It was a failure since you cannot set timers in a service worker since it may be killed at any moment (for example, Firefox seems to kill a service worker after one minute). So the background-sync API is not really polyfillable.

    Firefox started working on background-sync three years ago so I’m not as optimistic as you. I hope it will eventually land.

    • Background sync could use either IndexedDB or Cache API, but in the case of the fallback mechanism not all browsers support Cache API.

  2. John

    Great article. One question though… how do you debug the offline mode?

    You can’t do it on the same machine as the server because when you check the offline box in Chrome DevTools, or enable Work Offline in firefox, the service-worker isn’t actually put in offline mode, so the web requests go through anyway.

    You can’t do it on a separate device without faffing around with certificates because the service worker won’t be installed unless you are on https with a validated certificate.

    Is there an easy and secure way of testing offline mode?

    • Checking offline in the Chrome devtools Network tab should prevent your Service Worker from accessing the network.

    • John

      When I tested again last week checking offline certainly doesn’t take the service worker offline. To test it, try this.
      1. Add a service worker to a site.
      2. Forget to handle the html files in the fetch handler.
      3. Load the page once.
      4. Check offline and refresh the page.
      It loads even though it hasn’t stored the html pages.

    • You are right! I didn’t realise this. However, while Service Worker requests go through, as far as I can see from debugging my Service Worker examples the sync event does not ever fire in offline mode.

      It seems that to get reliable offline behaviour we must turn off the computer’s network for now. Does anyone have a nicer solution?

    • John

      Besides Google’s own documentation for the workbox library says about testing background sync

      * ⚠️ DO NOT USE CHROME DEVTOOLS OFFLINE ⚠️ The offline checkbox in DevTools only affects requests from the page. Service Worker requests will continue to go through.

      https://developers.google.com/web/tools/workbox/modules/workbox-background-sync#testing_workbox_background_sync

Wrap your code in <pre class="{language}"></pre> tags, link to a GitHub gist, JSFiddle fiddle, or CodePen pen to embed!