Chapter 7. Ensuring Offline Functionality with Background Sync

There are few things more frustrating to us as users than filling out a form, clicking the Submit button, and getting a connection error in response. Filling out forms can be slow and frustrating, especially on mobile devices—having all of that work taken away from us just because we happened to step into the elevator at the wrong moment can drive many of us to tears.

A slow, flaky connection can lead to just as much frustration. What if we click a button on a site, wait for something to happen, then once we get tired of waiting, try and navigate away before the action completes? The action might complete without our knowledge, or it might not. As developers, we have had to resort to techniques such as listening to the page’s onbeforeunload event and displaying a message begging the user to wait some more (also known as OK/Cancel—I honestly can’t remember which of those means wait; see Figure 7-1).

As users, we wouldn’t accept software that just erases all of our hard work from time to time. We have grown accustomed to expecting our software and our mobile apps not to treat us this way every time a nearby cell tower is experiencing too much load. Unfortunately, this is still the reality of trying to get things done on the mobile web.

This inherent unreliability has been at the heart of the divide between the web and native apps. Now a new technology called background sync finally lets us do something about this.

Exit confirmation
Figure 7-1. Exit confirmation

Background sync allows us to make sure any action the user takes (whether filling out a form, clicking an RSVP button, or sending a message) completes—no matter the connection. Background sync actions will complete even if the user has left our web app, never to return, and then closed her browser. This makes it one of the most valuable tools browsers have given us in recent years. It may not be as sexy as push notifications, homescreen icons, or even offline functionality. When implemented properly, its impact is invisible to the user—but background sync works tirelessly in the background, making sure your users get things done.

For the user, being able to trust your progressive web app to always work (not just some of the time, and not depending on the connection, reception, or weather) means the difference between the old web and one that works as well as native apps.

For a business, allowing users to book a ticket, subscribe to a newsletter, or send a message even when their connection fails can have a meaningful positive impact on their bottom line.

As unassuming and relatively simple to implement, background sync is one of the key components for a modern progressive web app and the final piece in our offline-first puzzle.

How Background Sync Works

The essence of using background sync is moving actions away from the page’s context and running them in the background.

By placing these actions in the background, they are safe from the volatile nature of any individual web page. A web page can be closed, the user’s connection may come down, and even servers fail from time to time. But as long as the browser is installed on the user device, a background sync action will not go away until it completes successfully.

You should consider using background sync for any action that you care about beyond the life of the current page. Whether the user is sending a message, marking a to-do item as done, or adding an event to a calendar, background sync ensures that actions complete successfully.

Using background sync is relatively straightforward. Instead of performing an action on your page (such as an Ajax call), you register a sync event:

navigator.serviceWorker.ready.then(function(registration) {
  registration.sync.register('send-messages');
});

This code can be run from your page. It gets the active service worker’s registration object and uses it to register a sync event named "send-messages".

Next, you can add an event listener to the service worker that will listen for this sync event. This event will include the logic to perform the action in the service worker instead of on the page:

self.addEventListener("sync", function(event) {
  if (event.tag === "send-messages") {
    event.waitUntil(function() {
      var sent = sendMessages();
      if (sent) {
        return Promise.resolve();
      } else {
        return Promise.reject();
      }
    });
  }
});

Notice how the event listener code uses waitUntil to make sure the sync event does not end until we tell it to. This gives us time to try and perform some action and either resolve the event successfully if it succeeds, or reject it. If we return a rejected promise to the sync event, the browser will queue the sync action to be tried again at a later time. The sync event named "send-messages" will keep retrying until it succeeds—even if the user has already left our app.

Important

Let’s look at our sample messaging app again and see how it might benefit from background sync.

For our messaging app to succeed, users must feel they can trust it. They should feel they can always open it, write down their thoughts, click Submit, and move on with their lives. They should not have to worry about the state of their connection before writing a message, and they should never be turned away with an error message and asked to try again later. A loss of connectivity is an eventuality that we must plan for so that we can handle it gracefully. Failure to do so destroys the user’s trust in our app.

WhatsApp’s native app demonstrates this perfectly. You can always open it (regardless of your connection), write a message, and know that it will be delivered as soon as possible (immediately, or as soon as you go online if you aren’t). Even if you close the app, you know you can trust it to deliver your message in the background. WhatsApp’s interface even communicates this in a clear and simple way. If you send a message while you are offline, it will go into the stream of messages just like any other message (reinforcing your confidence that it won’t be lost), but with a small watch icon to indicate it is scheduled to be sent. As soon as it is delivered successfully, the watch icon is replaced with a checkmark.

Adopting a similar pattern, our sample messaging app can use background sync to ensure message delivery (Figure 7-2). When a user sends a message, it can be added immediately to the app’s UI, along with a small icon to show that it is scheduled for delivery. It can then be sent to the server using a background sync action that will complete immediately if the user is online, or as soon possible if she isn’t. When the message is delivered, we can update the interface, changing the scheduled message icon to a timestamp.

This user experience communicates a feeling of trust in your app. Often this is just as important as implementing the technology itself. Chapter 11 explores this and other user experience considerations in progressive web apps.

bpwa 0702
Figure 7-2. The msgr app with background sync

The SyncManager

Now that we have seen the code for registering and listening to sync events, let’s understand how they work.

Any interaction with sync events is done through the SyncManager. The SyncManager is an interface of service workers that allows us to register sync events and get a list of current sync registrations.

Accessing SyncManager

We can access the SyncManager through the active service worker’s registration object. Getting this registration object is a bit different whether you are trying to access it from the service worker or from the page itself.

Inside a service worker, the service worker registration is easily accessible in the global object:

self.registration

In a page controlled by a service worker, you can access the currently active service worker’s registration by calling navigator.serviceWorker.ready, which returns a promise that resolves to the service worker registration object:

navigator.serviceWorker.ready.then(function(registration) {});

Once you have a service worker’s registration object, the rest of your interactions with the SyncManager are the same, whether you are interacting with it from a service worker or the page.

Registering Events

To register a sync event, you can call register on the SyncManager, passing it the name (also known as the tag) of the sync event you would like to register.

For example, to register a "send-messages" event from the service worker, you can use the following code:

self.registration.sync.register("send-messages");

To register the same event from a page controlled by a service worker, use the following code:

navigator.serviceWorker.ready.then(function(registration) {
  registration.sync.register("send-messages");
});

Sync Events

Let’s go over what happens when we register a sync event.

The SyncManager maintains a simple list of sync event tags. This list contains no logic of what the events are or what they do. Their implementation is left entirely up to the code that responds to the sync event in the service worker. The SyncManager just knows which events were registered, when they were called, and how to dispatch a sync event.

The SyncManager will dispatch a sync event for each of the registrations in its list when any of the following happens:

  1. Immediately after a sync event was registered

  2. When the user’s status changes from offline to online

  3. Every few minutes if there are existing registrations that haven’t completed successfully

Dispatched sync events can be listened for and responded to with a promise in the service worker. If that promise resolves, that sync registration will be removed from the SyncManager. If it rejects, it will remain in the SyncManager to be retried at the next sync opportunity.

Event Tags

Event tags are unique. If you register a sync event with a tag name that already exists in the SyncManager, the SyncManager will ignore it and not add a duplicate entry. This may seem like a limitation at first, but it is actually one of the most useful traits of the SyncManager. It allows you to group the handling of many similar actions (e.g., emails to send) into a single event. You can then register a sync event to take care of all the actions in the queue (e.g., an email outbox) every time a new action is added to it without having to check first if that event is already registered or is currently running.

For example, let’s say you are building an email service. Every time a user attempts to send a message, you can save the message to an outbox in IndexedDB and register a "send-unsent-messages" background sync event. Your service worker can then include an event listener that can respond to "send-unsent-messages" by going over every message in the IndexedDB outbox, attempting to send it, and removing it from the IndexedDB queue if it was sent successfully. If even a single message was not delivered successfully, the entire sync event could be rejected. The SyncManager will then dispatch that event again later, allowing you to try and empty the outbox again, knowing that only the messages that failed to deliver on the last event (along with any new messages created since) will be sent.

Using this setup, you never need to check if there are messages in the outbox or not. As long as there are unsent emails, a sync event will remain registered and periodically attempt to empty the outbox. In “Adding Background Sync to Our App”, we will look at this in practice as we maintain a list of hotel reservations made while the user was offline.

If you decide that you do want individual events, you can simply give them unique names such as "send-message-432", "send-message-433", etc.

Getting a List of Registered Sync Events

You can get a full list of all sync registrations that are scheduled to run using the getTags() method of the SyncManager.

Unsurprisingly, like most service worker interfaces, getTags() returns a promise. This promise resolves to an array of sync registration tag names.

Let’s look at a complete example of registering a sync event named "hello-sync" from inside the service worker, and then once it has registered, logging a complete list of all the currently registered events to the console:

self.registration.sync
  .register("hello-sync")
  .then(function() { return self.registration.sync.getTags(); })
  .then(function(tags) {
    console.log(tags);
  });

Running this code inside a service worker should log ["hello-sync"] to the console.

We can achieve a similar result from a page controlled by a service worker, by first getting the registration object using ready:

navigator.serviceWorker.ready.then(function(registration) {
  registration.sync
    .register("send-messages")
    .then(function() { return registration.sync.getTags(); })
    .then(function(tags) {
      console.log(tags);
    });
});

Running this code in a page controlled by a service worker should log ["send-messages"] to the console.

Last Chances

In some cases, the SyncManager may decide that it has tried to dispatch a sync event that keeps failing too many times. When that happens, the SyncManager will dispatch the event one last time, giving you one last chance to respond to it. You can detect when this happens using the lastChance property of the SyncEvent and deciding how to act:

self.addEventListener("sync", event => {
  if (event.tag == "add-reservation") {
    event.waitUntil(
      addReservation()
        .then(function() {
          return Promise.resolve();
        })
        .catch(function(error) {
          if (event.lastChance) {
            return removeReservation();
          } else {
            return Promise.reject();
          }
        })
    );
  }
});

The code to use background sync is surprisingly simple and straightforward. But implementing background sync in an existing web app isn’t always so simple. In the next section, we will look at how to tackle background sync in your projects.

Background Sync Browser Support

Background sync has been available in Chrome since version 49.

At the time of writing, it was being implemented in Opera, Mozilla Firefox, and Microsoft Edge.

Passing Data to the Sync Event

By moving the code to perform the action away from the page and into our service worker, we made sure it will be executed no matter what—but we also introduced a new complexity.

Most actions performed in a page require some data to complete. A page calling a function that sends a message might require the text of the message. A function that favorites a post will probably need the ID of the post. But when we register a sync event, the only thing we can pass to it is the name of the event. In other words, you can tell the service worker to send a message in the background, but passing the message text to it isn’t as straightforward as passing arguments to a function.

There are many approaches to dealing with this. Allow me to suggest three different ones.

Maintaining an Action Queue in IndexedDB

Perhaps the ideal way to approach this would be to have the page store the entities the user is acting on (e.g., messages, reservations, etc.) in IndexedDB before triggering the background sync action. Then the sync event code in the service worker can iterate over that object store and perform the required action on each entry. Once the action completes successfully, that entity can be removed from the object store.

Going back to our messaging app, this approach would have us adding every new message to a message-queue object store and then registering a send-messages background sync event to handle them. This event will then iterate over all messages in the message-queue, send each of them to the network, and finally remove them from the message-queue. Only once all messages have been sent, and the object store is empty, will the sync event be resolved successfully. If even a single message failed to deliver, a rejected promise can be returned to the event, and the SyncManager will launch the sync event again at a later time.

You will probably want to maintain separate object stores for different queues (e.g., one queue for outgoing messages and a different one for liking posts) and have different sync events to handle each of them.

Using this approach, we could replace code like this:

var sendMessage = function(subject, message) {
  fetch("/new-message", {
    method: "post",
    body: JSON.stringify({
      subj: subject,
      msg: message
    })
  });
};

with code like this:

var triggerMessageQueueUpdate = function() {
  navigator.serviceWorker.ready.then(function(registration) {
    registration.sync.register("message-queue-sync");
  });
};

var sendMessage = function(subject, message) {
  addToObjectStore("message-queue", {
    subj: subject,
    msg: message
  });
  triggerMessageQueueUpdate();
};

Then in our service worker, we would add this code:

self.addEventListener("sync", function(event) {
  if (event.tag === "message-queue-sync") {
    event.waitUntil(function() {
      return getAllMessages().then(function(messages) {}
        return Promise.all(
          messages.map(function(message) {
            return fetch("/new-message", {
              method: "post",
              body: JSON.stringify({
                subj: subject,
                msg: message
              })
            }).then(function() {
              return deleteMessageFromQueue(message); // returns a promise
            });
          })
        );
      );
    });
  }
});

Our event listener listens for sync events named message-queue-sync, then uses getAllMessages() to get all the messages queued in IndexedDB, and finally returns a promise to the sync event that resolves only if all the promises within it are resolved. This promise is created by passing an array of promises to Promise.all. We create this array of promises by running map() on the messages array and returning a promise for each message (a technique explained in “Creating An Array of Promises for Promise.all()”). Each of these promises will only resolve once that message was successfully sent and removed from the queue. Later, in “Adding Background Sync to Our App”, we will look at a similar example in more detail.

You can also attempt a slightly different take on this approach—storing both the queued objects as well as objects that have already been synced successfully in the same object store. When using this technique, you will also save the state of each object and update that state when the object syncs successfully. For example, you can store all of an app’s sent and unsent messages in the same object store. Along with the message contents, each message object will also contain its current state, such as sent or pending. The sync action can then open a cursor to iterate over all the messages with a pending state, send them, and then change their state to sent. Later in this chapter, we will use this approach to manage the Gotham Imperial Hotel’s reservations.

Maintaining a Queue of Requests in IndexedDB

There may come a time when you are working on an existing project in which modifying the structure of your app to store objects locally and tracking their state might be a luxury you cannot afford. A quick way to introduce background sync to your project can be to replace existing Ajax calls with a queue of requests.

Using this approach, you will replace each network request with a method that will store the details of that request in IndexedDB and then register a sync event which will go over all requests in that object store and run them one at a time.

In contrast with the previous approach, our sync event will be storing all the details needed to replicate each network request in IndexedDB. Our sync code does not need to understand what each action on our site means; it simply needs to blindly iterate over a list of requests and perform them.

Using this approach, we could replace code like:

var sendMessage = function(subject, message) {
  fetch("/new-message", {
    method: "post",
    body: JSON.stringify({
      subj: subject,
      msg: message
    })
  });
};

var likePost = function(postId) {
  fetch("/like-post?id="+postId);
};

with something like the following:

var triggerRequestQueueSync = function() {
  navigator.serviceWorker.ready.then(function(registration) {
    registration.sync.register("request-queue");
  });
};

var sendMessage = function(subject, message) {
  addToObjectStore("request-queue", {
    url: "/new-message",
    method: "post",
    body: JSON.stringify({
      subj: subject,
      msg: message
    })
  });
  triggerRequestQueueSync();
};

var likePost = function(postId) {
  addToObjectStore("request-queue", {
    url: "/like-post?id="+postId,
    method: "get"
  });
  triggerRequestQueueSync();
};

We replace all requests to the network with code that stores objects representing those requests in an object store named request-queue. Each object in this store represents one network request, along with every piece of information needed to replicate it. Next, we can add a sync event listener to the service worker, which will go over all the requests in request-queue, make network requests for each, and remove them from the object store:

self.addEventListener("sync", function(event) {
  if (event.tag === "request-queue") {
    event.waitUntil(function() {
      return getAllObjectsFrom("request-queue").then(function(requests) {
        return Promise.all(
          requests.map(function(req) {
            return fetch(req.url, {
              method: req.method,
              body:   req.body
            }).then(function() {
              return deleteRequestFromQueue(message); // returns a promise
            });
          })
        );
      });
    });
  }
});

Requests that complete successfully are removed from the IndexedDB queue (using deleteRequestFromQueue()). Requests that fail stay in the queue and return a rejected promise. If one or more of the requests returned a rejected promise, the queue of requests will be iterated over again in the next sync event (this time, without the requests that were fetched successfully).

For a sample implementation of the functions used to get objects from an object store, and other IndexedDB code, see Chapter 6.

Passing Data in the Sync Event Tag

When you just need to pass a simple value to your sync function, implementing a database to track every single action might sometimes seem like overkill. The following trick definitely feels a bit dirty, but sometimes a quick-and-dirty solution is exactly what you are looking for.

Let’s say your page allows users to “like” certain posts, an action that simply requires the ID of the post to be sent to a certain URL. Your existing code may look like this:

var likePost = function(postId) {
  fetch("/like-post?id="+postId);
};

As we have seen before, you could replace the code with an IndexedDB queue of posts to like, and then iterate over all of these posts. But sometimes there is value in keeping things simple. Replacing the likePost function with the following code can achieve similar results without having to maintain a database of posts to like:

var likePost = function(postId) {
  navigator.serviceWorker.ready.then(function(registration) {
    registration.sync.register("like-post-"+postId);
  });
};

Our sync event code can be kept almost as simple, simply testing that the event name begins with "like-post-", and then extracting the post ID from it:

self.addEventListener("sync", function(event) {
  if (event.tag.startsWith("like-post-")) {
    event.waitUntil(function() {
      var postId = event.tag.slice(10);
      return fetch("/like-post?id="+postId);
    });
  }
});

Adding Background Sync to Our App

Now that we have a basic understanding of background sync, it is time to get hands-on and use it to improve the Gotham Imperial Hotel’s web app.

On the top of the My Account page is a form that allows users to make new reservations. When users submit this form, the addReservation() function in my-account.js is called. This function creates a new reservationDetails object from the form inputs, giving it a status of "Awaiting confirmation". It then adds that object to the reservations object store in IndexedDB, renders it to the DOM, and finally makes an Ajax request to the server to make the reservation with the hotel.

But assuming the network will always be available is asking for trouble. Things might appear to work great when we are testing them on our local machine, but if a user tries to make a reservation just as she lost connectivity, this logic will fail miserably. If addReservation() is called while the user is offline, the new reservation will be entered into IndexedDB, rendered to the page, but the Ajax request will fail to let our server know that a new reservation was made. The reservation will appear on the page, and thanks to IndexedDB, it will remain there even after the user refreshes her browser. No matter what she does, she will see the reservation stuck in the "Awaiting confirmation" phase indefinitely, while the server will remain completely oblivious to it. This is endlessly frustrating to our users and a major nuisance to the hotel’s shareholders.

We can solve this by moving the request to create a new reservation from the page to a sync event in the service worker.

Here are the steps we need to accomplish:

  1. We will modify the addReservation() function to check if background sync is supported in the browser. If it is, it will register a sync-reservations sync event. If it isn’t, it will use a regular Ajax call as before.

  2. The code that adds new reservations to IndexedDB will be modified to add new reservations with a status of "Sending". This is how they will appear to the user until they are successfully added to the server, which will return a new status for them ("Awaiting confirmation" or "Confirmed").

  3. We will add an event handler to the service worker to respond to sync events. When a sync event named “sync-reservations” is detected, our event handler will go over every reservation with a "Sending" status and attempt to send it to the server. Reservations that are successfully added to the server will be updated in IndexedDB with their new status. If any request to the server fails, the entire sync event will be rejected, and the browser will attempt to run it again at a later time.

We begin by modifying addReservation() to check if background sync is available in the current browser. If it is, it will register a sync event instead of calling the server directly.

But first, start by making sure that your code is in the state we left it in at the end of the last chapter by running the following commands in the command line:

git reset --hard
git checkout ch07-start

Next, in my-account.js, modify the code for addReservation() so that it matches the code shown in this example:

var addReservation = function(id, arrivalDate, nights, guests) {
  var reservationDetails = {
    id:           id,
    arrivalDate:  arrivalDate,
    nights:       nights,
    guests:       guests,
    status:       "Sending"
  };
  addToObjectStore("reservations", reservationDetails);
  renderReservation(reservationDetails);
  if ("serviceWorker" in navigator && "SyncManager" in window) {
    navigator.serviceWorker.ready.then(function(registration) {
      registration.sync.register("sync-reservations");
    });
  } else {
    $.getJSON("/make-reservation", reservationDetails, function(data) {
      updateReservationDisplay(data);
    });
  }
};

We begin by modifying the addReservation() function to create the reservationDetails object with a "Sending" status instead of "Awaiting confirmation". We then use feature detection to make sure both ServiceWorker and SyncManager are supported by the current browser. If they are, we register a sync-reservations sync event. If they aren’t, we use $.getJSON to make the reservation on the page, just like we did before.

Before we create the event listener for this event, let’s make two small improvements to the reservations-store.js file. These improvements will allow us to easily get just the reservations that have a "Sending" status.

We begin by adding a new index on the status field of the reservations store.

In reservations-store.js, increase the DB_VERSION in the first line from 1 to 2 (or higher, if you created more versions). Next, in the same file change the onupgradeneeded function that is within the openDatabase() function so that it creates an index on the status field in the reservations object store. The code for this can be seen here:

request.onupgradeneeded = function(event) {
  var db = event.target.result;
  var upgradeTransaction = event.target.transaction;
  var reservationsStore;
  if (!db.objectStoreNames.contains("reservations")) {
    reservationsStore = db.createObjectStore("reservations",
      { keyPath: "id" }
    );
  } else {
    reservationsStore = upgradeTransaction.objectStore("reservations");
  }

  if (!reservationsStore.indexNames.contains("idx_status")) {
    reservationsStore.createIndex("idx_status", "status", { unique: false });
  }
};

This code demonstrates something we haven’t seen yet. In Chapter 6, we only saw how to create an index on new object stores. This time, as the object store may already exist for some of our users, we need to either add it to the existing object store, or create a new object store and then add the index to it.

Our code still follows the same version management pattern shown in “IndexedDB Version Management”, which advocates for making sure each change is needed before making it. Before creating the reservations object store, we make sure it does not exist. If it doesn’t, we create it and save a reference to it into the reservationsStore variable. If it already exists, we get a reference to it from the upgrade event’s transaction by calling event.target.transaction.objectStore("reservations").

Finally, when we are sure the reservations object store exists (either from a previous version or because we just created it), we check the object store’s indexNames property to see if it already contains our index. If it does not, we go ahead and create it.

The final change in reservations-store.js will allow us to use this new index to easily get all reservations with a certain status. To do this, we will improve the getReservations() function so that it can receive two optional parameters: an index name and a value to pass to that index.

Change the getReservations() function in reservations-store.js to look like the following:

var getReservations = function(indexName, indexValue) {
  return new Promise(function(resolve) {
    openDatabase().then(function(db) {
      var objectStore = openObjectStore(db, "reservations");
      var reservations = [];
      var cursor;
      if (indexName && indexValue) {
        cursor = objectStore.index(indexName).openCursor(indexValue);
      } else {
        cursor = objectStore.openCursor();
      }
      cursor.onsuccess = function(event) {
        var cursor = event.target.result;
        if (cursor) {
          reservations.push(cursor.value);
          cursor.continue();
        } else {
          if (reservations.length > 0) {
            resolve(reservations);
          } else {
            getReservationsFromServer().then(function(reservations) {
              openDatabase().then(function(db) {
                var objectStore =
                  openObjectStore(db, "reservations", "readwrite");
                for (var i = 0; i < reservations.length; i++) {
                  objectStore.add(reservations[i]);
                }
                resolve(reservations);
              });
            });
          }
        }
      };
    }).catch(function() {
      getReservationsFromServer().then(function(reservations) {
        resolve(reservations);
      });
    });
  });
};

The new function includes two changes. First, it allows getReservations() to receive two optional arguments (indexName and indexValue). Second, if the function receives those arguments, it will use them to open the cursor on that index (indexName) and not on the object store directly. It will then open the cursor with the value it would like to limit the results to (indexValue). If those arguments are not passed, it will behave just like before and return all the reservations.

With these two changes, our function can either return all results or just a subset of them, as shown here:

getReservations().then(function(reservations) {
  // reservations contains all reservations
});

getReservations("idx_status", "Sending").then(function(reservations) {
  // reservations contains only reservations with the status "Sending"
});

Now that everything is in place to handle unsent reservations in the service worker, we can go ahead and add the background sync event listener to it.

First, make sure that the first line in serviceworker.js imports the reservations-store.js file. It should look like this:

importScripts("/js/reservations-store.js");

Next, add the following code to the bottom of serviceworker.js:

var createReservationUrl = function(reservationDetails) {
  var reservationUrl = new URL("http://localhost:8443/make-reservation");
  Object.keys(reservationDetails).forEach(function(key) {
    reservationUrl.searchParams.append(key, reservationDetails[key]);
  });
  return reservationUrl;
};

var syncReservations = function() {
  return getReservations("idx_status", "Sending").then(function(reservations) {
    return Promise.all(
      reservations.map(function(reservation) {
        var reservationUrl = createReservationUrl(reservation);
        return fetch(reservationUrl);
      })
    );
  });
};

self.addEventListener("sync", function(event) {
  if (event.tag === "sync-reservations") {
    event.waitUntil(syncReservations());
  }
});

Before delving into the details of createReservationUrl() and syncReservations(), let’s first look at the last part of this new code. We use self.addEventListener to add a new event listener for sync events. This event listener will respond to events tagged “sync-reservations,” telling them to waitUntil the promise returned by syncReservations() resolves or rejects before deciding whether to resolve or reject the sync event. If the syncReservations() promise resolves, the sync-reservations sync event will be removed from the SyncManager (until we register it again). If the promise is rejected, the SyncManager will keep this sync registration and trigger the event again later.

What is the promise created by syncReservations() that determines the outcome of the entire sync event? Broadly speaking, syncReservations() goes over every reservation marked "Sending" in IndexedDB, tries to send it to the server, and returns a promise that resolves only when every single reservation has been sent successfully. If even a single reservation fails, the entire promise returned by syncReservations() fails.

To achieve this, syncReservations() begins by getting all the reservations that have a status of "Sending" using the getReservations() function. This function returns a promise containing an array of all the reservations we need to send. We then use Promise.all() to wrap all the individual promises to send the reservations and return a single promise that will determine the outcome of the entire syncReservations() function.

To do this, we need to pass Promise.all() an array of promises. We create this array by taking the array of reservation objects and transforming it into an array of promises using the Array.map() method. We use map() to go over each reservation, creating a fetch request to the server to create this reservation. fetch() returns a promise that we return and place in the array of promises passed to Promise.all().

For an explanation of how to use Promise.all() and Array.map() to create an array of promises, see “Creating An Array of Promises for Promise.all()”.

Finally we have the createReservationUrl() function. This function uses the URL interface to create a new URL object representing the web address to send the fetch request to. This code is simply a more elegant way to create a URL with a query string than concatenating strings, values, ampersands, and question marks manually. Our function takes an object containing reservation details and returns a URL object containing those details in the query string:

console.log(
  createReservationUrl({nights: 2, guests: 4});
);
// This will return a new URL object pointing at
// http://localhost:8443/make-reservation?nights=2&guests=4

With all of these changes in place, you can visit the My Account page again. This time, once the page loads, simulate an offline state (using the browser’s developer tools or by taking down the development server) and try to make a new reservation. The reservation will be added to IndexedDB and the DOM, the sync event will register, but the server will be unreachable. The reservation should remain in a "Sending" state, as can be seen in Figure 7-3. Next, bring back the connection to the server, and within a few moments, the sync event will be dispatched again, causing the reservation to change to a "Confirmed" status.

Reservation made with background sync
Figure 7-3. Reservation made with background sync
Note

If you restored connectivity, have been waiting for the sync event to run, and have begun wondering if it ran already or not, you can run the following code in your browser’s console:

navigator.serviceWorker.ready.then(function(registration) {
  registration.sync.getTags().then(function(tags) {
    console.log(tags);
  });
});

This will output the full list of currently registered sync events. If the reservations sync event is still registered, the code should log out ["sync-reservations"] to the console.

What is perhaps most impressive about background sync is that the sync event to the server will happen even if you close the Gotham Imperial Hotel site. The SyncManager keeps track of all the pending sync registrations and tirelessly makes sure things get done in the background. This would never have been possible without a service worker—a script that can respond to events even after the user has closed your progressive web app.

This brings up one last thing that is missing from our sync event. When our sync event successfully creates a reservation, the fetch request returns a new reservation details object containing new details. This includes the final price of the reservation, as well as the updated status of the reservation. We need to update the reservation’s details in IndexedDB so that we can show the most up-to-date information to our user. More importantly, we need to update the reservation’s status so that it isn’t sent a second time the next time a sync-reservations event is registered.

Update the syncReservations() function in serviceworker.js so that it looks like the following:

var syncReservations = function() {
  return getReservations("idx_status", "Sending").then(function(reservations) {
    return Promise.all(
      reservations.map(function(reservation) {
        var reservationUrl = createReservationUrl(reservation);
        return fetch(reservationUrl).then(function(response) {
          return response.json();
        }).then(function(newReservation) {
          return updateInObjectStore(
            "reservations",
            newReservation.id,
            newReservation
          );
        });
      })
    );
  });
};

The only change in the latest version of syncReservations() is that when fetch() resolves we no longer immediately consider the promise resolved. Now, when the promise returned by fetch resolves, the new function within then is called with the response to the fetch. This object contains JSON, which we parse by calling response.json(). This returns a promise, containing a simple JavaScript object with the reservation details, which we then pass to our updateInObjectStore() function.

Now, even users that made a reservation while they were offline will have their latest reservation data reflected in their local IndexedDB object store. Even if the sync event succeeds in making the reservation after they have left our site, we make sure the data in their IndexedDB object store is kept up to date.

Summary

Background sync has the potential to become one of the most important building blocks of a modern progressive web app. It is one of those technologies that can be absolutely vital to the user experience, yet completely invisible to the user—until it doesn’t work.

As you tackle adding background sync to your own app, you will most likely come up against two main challenges.

The first is moving the logic (along with all the data needed to run it) from the page to the service worker. This is what we tackled in this chapter.

The second is a matter of communicating the outcome of background sync event back to the page and the user. You will often want to modify the page based on the outcome of background sync actions (e.g., visually mark a message as sent or a post as having been liked). Since the service worker has no direct access to the page’s window, we need a way to communicate the outcome of these actions from the service worker back to the page. In Chapter 8 we will explore how to accomplish this by posting messages between the service worker and the page.

But this raises another interesting challenge. What if by the time the sync event succeeds, the user has already left the site? How can we let him know that it was received? How can we update him if at a later point its status changes? In Chapter 10 we will learn how to use push notifications to always keep our users up to date.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset