Background Synchronization

Eduardo Valencia
Super-coder
Published in
16 min readMay 16, 2020

--

Welcome back! This is my third article in my series of “Progressive Web App” articles. If you have not read my other articles, please read them since they explain the important concepts behind progressive web apps.

Progressive Web Apps and React

Workbox and Versioning

Imagine that you are sitting on your couch and using Reddit. After a post makes you laugh, you decide to upvote it. Then, you close the app. Wouldn’t it be awful if the upvote did not get sent because you closed the app? This is the problem that background synchronization aims to solve. By using background synchronization, you can make requests even when the browser or page is closed. In this article, I will explain how background synchronization works, how to implement it without Workbox, and how to implement it with Workbox.

Before we begin, I would like to remind you that you must understand how service workers update. Please visit the article below to learn how.

How Service Workers Update

New App

In the previous section, I used a blog application to demonstrate precaching in Workbox. To help you see background sync in action, I have made a few changes to the previous application. The app is now a to-do app, and you can see the list of tasks on the home page.

The app's task page. Each task has a checkbox near it.

Clicking on the “Add Task” button at the top will let you add a task with a name.

A modal with an input for the task name. It has the "Close" and "Add Task" buttons at the bottom.

Installation

If you want to follow along and install the app, clone it into a new directory using the following code:

git clone https://github.com/eduardo-valencia/progressive-blog-example.git
git checkout tasks

Then, change into the directory that was created and install all of the dependencies.

npm install

Finally, run npm start and the app will load.

Background Synchronization

A diagram describing the background synchronization process. Background sync requires registering a tag and creating an event listener. To create a task, the app verifies that the service worker is available. If it is available, then it registers a tag and adds the tasks to the database. In contrast, if it is not available it makes a network request directly to the API. The sync event listener gets an event tag and verifies that it describes adding a task. If it does, then it gets the tasks from the database, creates each of the tasks, and deletes the old tasks.

Background synchronization involves postponing a request until the application is back online. Even if the page or browser is closed, the app can make a request once it detects that your phone or computer is back online. How does the application make a request when it is closed? By using a service worker and registering an event listener, you can postpone requests until the application is online.

Background synchronization involves four main steps.

First, you must check that service workers are available. This is because service workers are not available on all browsers, such as iOS Safari. You can check whether service workers are available by using the following code:

if (navigator.serviceWorker) {
// ...Use background sync
}

Second, you must tell the app to use background sync by registering a sync tag inside of your main application. A sync tag describes the action that you want to take. For example, if you wanted to create a task, you could name the sync tag “addTask.” By registering a sync tag, you are telling the service worker that you have postponed certain requests.

The following code will provide an example of registering a sync tag:

// Inside of your main application.

const registerTag = async () => {
// Wait for the service worker.
const registration = await navigator.serviceWorker.ready
// Using the registration, register a tag.
await registration.sync.register('addTask')
}

To register a sync tag, you must first check that the service worker is ready. The app will give you a registration once the service worker is ready, and you can use it to register a sync tag. Notice how I used await to get the registration. Then, I used the register method to register a tag with the name of “addTask.” This is the first step in using background synchronization.

Third, you must use a sync event listener inside of your service worker to detect when the application goes back online. Whenever you register any tag, the sync event listener inside receives the tag once the application is back online. Then, the event listener receives the event’s tag name, which is addTask in the example. The following code shows you how to create a sync event listener.

self.addEventListener('sync', async (event) => {
const { tag } = event
if (tag === 'addTask') {
// Do something here...
}
})

Notice how the event contains the tag property. You should check the name of the tag because you may want to register another tag in the future, and you may want to take different actions for different tags.

Finally, you should make the requests that you postponed.

const addTask = () => fetch('/api/task', { method: 'POST' })

self.addEventListener('sync', async (event) => {
const { tag } = event
if (tag === 'addTask') {
await addTask()
}
})

Notice how I called the addTask function to make a request. This means that whenever I register the “addTask” tag and the application is online, I will make a request to create a task. However, I do not have enough information to make the request. Specifically, I do not have any data for the request’s body. Usually, you would have this information in the main application. Therefore, I need a way to share information between the main application and the service worker. The solution to this problem is Indexed DB.

Indexed DB and Dexie

A diagram describing how the application's data is affected. First, the main application adds the task data to Indexed DB. Then, the service workers gets the task data from the database and uses it to create the tasks inside of the API.

To make a request, you may need information that is not available to you from within the service worker. You can use Indexed DB, a client-side database, to store information inside of your main app and then access it from within the service worker. A database is much like a JavaScript array in that you can add, get, update, or delete specific items. To facilitate the process of using the database, we will install and use a library named “Dexie.”

Dexie is a library that helps you interact with Indexed DB. To use it, you must create a database with a specific name. Think of this as a folder inside of your computer. Each database can have multiple tables (also called “stores”), which are groups of information. Tables are like nested folders inside of the main database folder. Inside of each table, you can create specific entries, which are the individual items in the database. An entry is a JavaScript object and can have many properties, such as “name,” “address,” or “email.” Next, I will show you how to install Dexie and create your database.

Install Dexie now by going inside of the project’s directory and running the following command:

npm i dexie

Creating a Database

After installing it, you can create your own database by specifying the name of the database and a table. Imagine that you want to create a database that holds the names of your favorite songs. This scenario would be represented by the following example:

import Dexie from 'dexie'

const setupDatabase = () => {
const database = new Dexie('MyNewDatabase')
database.version(1).stores({
songs: '++id',
})
return database
}

Each database must have a version, which is 1 in this example. Notice how I called the stores method with an object. In this case, I named the table songs. However, Dexie requires that each song has an ID so that it can easily find it. Therefore, I added the string “++id” to tell Dexie to assign an ID number automatically each time I add something to the database. I have now created my first database! Now I will show you how to add items to it.

Adding Items

To add a song, you should use the add method on the table that you want to add an item to.

const addSong = async () => {
await database.songs.add({
name: 'myNewSong',
})
}

Notice how I accessed the table using database.songs and then added the song with a name. It is that simple!

Getting Items

You can also get songs from the database:

const getAllSongs = () => {
return database.songs.toArray()
}

The getAllSongs function returns all songs in the database as an array. If you had a song in the database and you called this method, you would get the following output:

;[{ name: 'myNewSong', id: 1 }]

Getting a Specific Item

You can get a specific song from the database using the get method.

const getSong = (id) => database.songs.get(id)

To get a specific song, I need the song’s id. Calling this function with an id of 1 would give you a song like below:

{name: "myNewSong", id: 1}

Updating an Item

You can also update a specific item in the database:

const updateSong = (id) =>
database.songs.update(id, { name: 'My Updated Name' })

Calling this function would update the name of a song to be “My Updated Name.”

Deleting Items

Finally, you can delete a song.

const deleteSong = (id) => database.songs.delete(id)

Using these methods, we can manipulate the contents of the database and save the information we need to make requests for the app.

Background Sync Implementation

To implement background sync, I will use Indexed DB to store the tasks in the main app. This will allow me to get the tasks inside of the service worker and add them whenever a “sync” event fires. First, I will create a database to store the tasks. Second, I will add the tasks to the database and register a tag, indicating that I will create a new task. Third, I will create a sync event listener so that the service worker receives the tag. Fourth, I will use the list of task data inside of the database to create the tasks. Finally, I must delete the tasks from the database so that I do not add duplicates the next time I create a new task. To begin, I will show you how I stored the tasks in the database.

To store the tasks, I created a new file at src/services/indexedDb.js to set up the database.

import Dexie from 'dexie'

const setupDatabase = () => {
const database = new Dexie('ProgressiveApp')
database.version(1).stores({
tasks: '++id',
})
return database
}

export default setupDatabase

Here, I named the database “ProgressiveApp” and created a new table named “tasks.” Notice how I set the id as auto-incrementing. I can now import this file both in my main app and in the service worker to set up the database. Next, I will explain how I stored the tasks.

Second, I created methods inside of the App.js file to create, get, update, and delete tasks. For this example, I will only add background sync for task creation. Recall that I must first check whether the service worker is available. If the service worker is available, then I must store the tasks in the database and send the sync tag. Take a look at how I created the tasks below. Please note that this is a simplified version of the App component.

import setupDatabase from '../services/indexedDb'

class App extends Component {
// ...other methods

database = setupDatabase()

// Creates a sync tag called 'addTask'
registerBackgroundSync = async () => {
const registration = await navigator.serviceWorker.ready
await registration.sync.register('addTask')
}

storeTaskAndSendSignal = async (task) => {
await this.database.tasks.add(task)
await this.registerBackgroundSync()
}

requestAddTask = (task) => tasksApi.post('/', task)

// Checks if service worker is available because not all browsers have it.
useBackgroundSyncOrRequest = (task) => {
const serviceWorkerExists = navigator.serviceWorker
// Creates task with background sync only if the browser supports service workers.
if (serviceWorkerExists) {
return this.storeTaskAndSendSignal(task)
}
// Creates task without background sync.
return this.requestAddTask(task)
}

create = async (task) => {
await this.useBackgroundSyncOrRequest(task)
}
}

First, I set up the database so that I could access it from the App class using this.database. This allows me to use Dexie’s methods to add, create, get, update, or delete items. Inside of the create method, I called the method useBackgroundSyncOrRequest, which is responsible for verifying whether the service worker is available. When the service worker is available, it adds the task data to the “tasks” table and sends a tag with the name of “addTask.” Otherwise, it will make a network request to the API and create the task instantly, without using background synchronization. This allows both browsers that support background sync and those that do not support it to use the app.

Next, I need to pass the create method down to the AddTask component, since this component displays the form for creating the task. Although I could pass down the methods manually using props, I will use the React context instead. React context allows you to easily pass data through multiple child components by passing it as a property to a provider and having child component access the data with a consumer. Think of the provider as a restaurant, while a consumer as one of its customers. Just as the restaurant serves food to the customer, the provider gives data to the consumer. You can read more about it in the article in the resources section below. The following example shows I created the provider.

// TasksContext.js

import React from 'react'

const TasksContext = React.createContext(null)

export default TasksContext

I created a context in the TasksContext.js file so that I can import it in both the App and AddTask components. Next, I will use the Provider property of the TasksContext to pass down the data.

// App.js

class App extends Component {
// ...other methods

// Group task methods together.
getProviderValue = () => ({
tasks: this.state.tasks,
create: this.create,
update: this.update,
delete: this.delete,
})

render() {
return (
// Allows child components to access methods.
<TasksContext.Provider value={this.getProviderValue()}>
<Layout>
<h1>Tasks</h1>
<AddTask />
<Tasks />
</Layout>
</TasksContext.Provider>
)
}
}

First, I created an object with the list of tasks with the create, update, and delete methods. By passing them down to the TasksContext.Provider component as the value property, I can allow other child components to use them.

Then, I used these methods inside of the AddTask component to add a task with a name whenever the form is submitted. Please look at the code below.

// AddTask/index.js

export class AddTask extends Component {
// ...other methods

// Allows me to access the data from the provider using this.context.
static contextType = TasksContext

addTask = async () => {
const task = {
title: this.state.title,
isCompleted: false,
}
await this.context.create(task)
}

render() {
const id = 'add-task'
const formId = `${id}__form`
return (
<TaskModal id={id} formId={formId}>
<form onSubmit={this.onSubmit} id={formId}>
<Name setName={this.setTitle} />
</form>
</TaskModal>
)
}
}

In this component, I create the task and add it to Indexed DB. Notice how I set the contextType to the TasksContext, which causes the provider to pass the data (the object with the create, update, and delete methods) to the AddTask component. I can access this data using this.context. In the addTask method, I called this.context.add to create a new task inside of Indexed DB. By adding the task inside of the database, I can store the tasks that I want to synchronize inside of Indexed DB and access them in my service worker. Next, I will show you how I modified the service worker.

To synchronize the data, I must first create an event listener. Recall that a sync event listener fires whenever the network is available, meaning that I should add the tasks whenever the “sync” event fires. The following code shows how I created the event listener:

self.addEventListener('sync', async (event) => {
const { tag } = event
if (tag === 'addTask') {
const handler = new AddTaskHandler()
await handler.addTasksAndRemoveOld()
}
})

Here, I created an event listener for the “sync” event. Recall that this event only fires when the application is back online and that it receives the tag’s name. If the tag’s name is “addTask,” then I should add tasks. To add tasks, I created a new handler called AddTaskHandler. Recall that the service worker must get the list of tasks, make requests for those tasks, and delete them so that it does not add duplicate tasks. Take a look at the handler below, but start reading from the addTasksAndRemoveOld method.

class AddTaskHandler {
constructor() {
this.database = setupDatabase()
}

getTasksToAdd() {
return this.database.tasks.toArray()
}

getTaskData({ title, isCompleted }) {
const task = { title, isCompleted }
return JSON.stringify(task)
}

addTask(baseTask) {
const stringifiedData = this.getTaskData(baseTask)
const config = {
method: 'POST',
body: stringifiedData,
headers: { 'Content-Type': 'application/json' },
}
return fetch('/api/tasks', config)
}

async addAllTasks(tasks) {
const promises = tasks.map(this.addTask.bind(this))
await Promise.all(promises)
}

getTaskIds(tasks) {
return tasks.map(({ id }) => id)
}

async deleteTasks(tasks) {
const ids = this.getTaskIds(tasks)
await this.database.tasks.bulkDelete(ids)
}

async addTasksAndRemoveOld() {
const tasks = await this.getTasksToAdd()
await this.addAllTasks(tasks)
await this.deleteTasks(tasks)
}
}

First, the getTasksToAdd function gets the list of tasks, gets their IDs, and calls the addTask method for each one. After making a network request for each task, I no longer need the tasks in Indexed DB. Since the database should only hold the tasks that the app needs to add, I called the deleteTask function with the task IDs to delete all tasks that were in the database. This way, the app can add tasks whenever the app is back online.

To test the app, start your server by running npm start and go to http://localhost:3000. You should update the service worker by either reloading or closing all of the app’s tabs and opening them again. Since we want to test whether the app works without having access to the API, turn off your server by typing ^C into your terminal. Click on the “Add Task” button in the navigation bar and enter a task name. Then, click enter to submit the form. Although this will not add a task because your server is offline, the task should be stored in Indexed DB. Next, restart your server by running npm start. The app should now detect that you are online and try to make the request. Once the page reloads, you should see your new task in the list of tasks. Congrats! You have added background sync into the app!

This may seem like a lot of work, but Workbox has a tool that helps developers add background synchronization more efficiently: the workbox-background-sync package. In the following section, I will show you how to use Workbox to add background sync.

Adding Background Sync Using Workbox

Using Workbox, we can add background synchronization much more easily. To use it, we need to follow a simple process:

  1. Register a route that Workbox should use background sync for.
  2. Make a request for adding a task from the app.
  3. Register a sync tag.

Once we make a request to the API, Workbox will detect whether the request fails. If it fails, then it will use background synchronization to postpone the request until the application is back online. Next, I will show you how to install the Workbox Background Sync package.

To install the package, run the following command in your terminal:

npm install workbox-background-sync

First, we must make a request to the API to create a new task. The following code shows how I created a task from the App component:

// src/App.js
class App {
// ...other methods

registerBackgroundSync = async () => {
const registration = await navigator.serviceWorker.ready
await registration.sync.register('addTask')
}

storeTaskAndSendSignal = async (task) => {
await tasksApi.post('/', task)
await this.registerBackgroundSync()
}

requestAddTask = (task) => tasksApi.post('/', task)

useBackgroundSyncOrRequest = (task) => {
const serviceWorkerExists = navigator.serviceWorker
if (serviceWorkerExists) {
return this.storeTaskAndSendSignal(task)
}
return this.requestAddTask(task)
}

create = async (task) => {
await this.useBackgroundSyncOrRequest(task)
}
}

Notice how instead of adding the task to Indexed DB, I called tasksApi.post to add a task using a network request to the API. I still registered a tag named “addTask,” since this is what Workbox will use to identify that it should add a task. I have finished modifying the App component. Next, I will show you how I changed the service worker to use Workbox.

To use background sync with Workbox, I must first import the workbox-background-sync package. Recall that Workbox uses strategies to respond to requests, some of which include StaleWhileRevalidate and NetworkOnly. Each strategy can accept plugins, which add more features. This Workbox Background Sync package contains the BackgroundSyncPlugin, which can be used with the NetworkOnly strategy to add background synchronization. The following code shows you how I accomplished this:

import { registerRoute } from 'workbox-routing'
import { StaleWhileRevalidate, NetworkOnly } from 'workbox-strategies'
import { BackgroundSyncPlugin } from 'workbox-background-sync'

const backgroundSync = new BackgroundSyncPlugin('addTask')

registerRoute(
new RegExp('/api/tasks'),
new NetworkOnly({ plugins: [backgroundSync] }),
'POST'
)

First, I configured the plugin by creating a new instance of it with new BackgroundSyncPlugin('addTask'). “addTask” is the tag that Workbox uses to identify what is being synchronized, so it must be the same tag that is registered inside of the App component. Then, I registered a route for “/api/tasks” using the NetworkOnly strategy, which means that it always requests data from the network instead of the cache. Inside of the strategy, I added an object with the plugins property, which includes the configured backgroundSync plugin. Finally, notice how I specified “POST” as the third argument in the registerRoute function. By default, Workbox’s strategies only work for “GET” requests, so I had to specify that it should work for POST requests. Next, we will test the app.

To test the app, you should repeat the same process as before. First, enable your server by running npm start. Second, go to the app on http://localhost:3000. Third, turn off your server so that the POST request to the api will fail. Then, try and create a new task. After the request fails, Workbox should store the task. Fifth, turn on your server again. Workbox will not instantly detect that you are back online because it only checks the network status every few minutes. To make the service worker create the tasks immediately, you should manually register a tag. Open the Chrome Development Tools and go to the “Application” tab. Then, click on the “Service Workers” section in the sidebar. You should see an input field labeled “sync” on the right side.

The Chrome Developer Tools with a field labeled "Sync" on the right side. It has the tag "workbox-background-sync:addTask."

In the input field, type workbox-background-sync:addTask. Notice how this is the tag we registered prepended with “workbox-background-sync.” Finally, click the “Sync” button. The tasks should now be created. Reload the page to see the newly created tasks. Congratulations! You have added background synchronization using Workbox!

Conclusion

In this article, you learned that background synchronization postpones requests until the app is online. Background synchronization works even when the application and browser are closed, but not all browsers support it. You learned the basic concept behind background sync and learned to use Dexie with Indexed DB to store data in a database. Then, you learned how to make an event listener in the service worker, get items from the database, and make requests for them. Finally, you learned that Workbox provides a much more efficient solution for adding background synchronization with the workbox-background-sync package. By registering a route and using the plugin, you can automatically postpone requests until the network is available. Thank you for reading this article! If you have any questions, please feel free to ask them in the comments below! In the next article, I will show you how to prepare your application for production.

Resources

Context — React

Workbox Background Sync

--

--