loading...

IndexedDB with Push Notifications

adrienshen profile image adrienshen ・8 min read

Introduction to Progressive Web Apps

Ever since Jake Archibald reveal a glimpse of what progressive web apps and service workers can do in the Google I/O 2016 event, I have been fairly optimistic about the potential applications this new set of web technologies can solve in developing the next generation of mobile web applications. PWAs allowed the discoverability of the open web to merge with the engagement and responsiveness of the native apps. It is really the best of both worlds. PWAs do this with rigorously caching website assets and scripts via service workers, using service workers for push notifications and background sync, and features like "Add to Home Screen" to give the native app like feel. This is just a simple introduction of Progressive Web Apps. To learn more, just out the full Google PWA tutorials and the various Google I/O talks. What I want to show in this post is the usages of accessing IndexedDB on receiving a web push notification and how it can be used to implement some pretty neat features.

Push Notifications on the Web

One of the core features that the Service Worker makes possible is to allow the web browser to receive web push notifications which used to be in the exclusive domain of native mobile applications. This feature is implemented in every major desktop and mobile browser now except Safari. Businesses, for example, can use these web push notifications to notify that a customer payment was made to a merchant progressive web app used by the cashier side. There is a server-side implementation involve that needs to send the push to the designated push service, but you can use curl or Postmen to simulate that. The client application push listener code in the service worker file will look something like this:

// most basic push notification example
self.addEventListener('push', function(event) {
    if (event.data) {
        const pushTitle = "A simple push notification";
        const pushOptions = {
            body: "push content body",
            icon: "./images/logo-192x192.png",
            vibrate: [100,50,100],
        };

        self.registration.showNotification(
            pushTitle,
            pushOptions,
        )
    }
});

When the device receives a push from the browser specific push service, the Service Worker will wake up for the moment and execute the code inside the handler and display the appropriate push message to the user. There are many settings that can be configured in the push such as priority levels, badges, tags, and event sounds. For a more comprehensive explanation see here: https://web-push-book.gauntface.com/

Scope of Service Workers

Service Workers is an amazing tool and what makes the modern generation of native like web applications possible. There are many introductions to Service Workers such as this one here, but the important concept to realize here is that Service Workers do not run in the same environment as our regular Javascript code. It means that Service Workers do not have access to normal browser APIs like the DOM, nor can it access APIs like Geolocation or LocalStorage. This was not obvious to me at first and can cause some frustrations in the features we need to implement. Instead of directly accessing the page DOM elements, the Service Worker will need to send a message through an API such as PostMessage which the app will listen to, and respond with a handler. But then, what if we need functionalities like user Geolocation at the time of say, a Push Notification event? This is where IndexDB comes in.

Why use IndexedDB?

IndexedDB is the recommended web browsers data object store for large amounts of data and more structure storage. There are other persistent storage on the browser like LocalStorage/SessionStorage, WebSQL, and AppCache as well. LocalStorage has certain limitations such as 5mb to 10mb maximum storage on most browsers and all data is saved in strings. I only use it for simple scenarios such as remembering if a user has already seen the tutorial slides for instance. WebSQL and the old Application Cache are no longer being supported so probably not recommend to be used when developing an offline app in 2018.

IndexedDB has:
- Low, level flexible interface for developers
- Mostly asynchronous
- Transactions, all database operations must be part of a transaction
- Most importantly, it is accessible via Service Workers which is our primary usage here

Store Geolocation in IndexedDB

We know that Service Workers do not permit access to Geolocation API directly nor can it access LocalStorage. So let us store the Geolocation data in IndexDB instead. We will be using Jake's idb helper library so we can utilize Promises which provides a cleaner interface.

// idb.js
import idb from 'idb';

export const idbPromise = idb.open('realtime-app', 2, db => {
    switch (db.oldVersion) {
        case 0:
        // placeholder so that block executes if there's no db
        case 1:
            console.log("creating object store: deviceGeolocation");
            db.createObjectStore("deviceGeolocation", {autoIncrement: true});
    }
});

We need a way to preserve the integrity of the database updates, so we export a helper idbPromise that will take care of database updates for users with different database versions. Next, we use the Geolocation API to query the user device location.

import { idbPromise } from "idb.js"

export const calculateGeoLocation = () => {
    if (navigator.geolocation) {
        navigator.geolocation.watchPosition(position => {
            _storeGeoIndexedDB(position.coords);
        })
    }
}

const _storeGeoIndexedDB = ({Lat, Lon}) => {
    if (!window.indexedDB) return;
    const Timestamp = new Date();

    idbPromise.then(db => {
        let tx = db.transaction('deviceGeolocation', 'readwrite');
        let deviceGeolocation = tx.objectStore('deviceGeolocation');
        let entry = { Lat, Lon, Timestamp };
        deviceGeolocation.add(entry);

        return tx.complete;
    })
}

We create a function that will query the geolocation and store that as an object inside our deviceGeolocation database using the helper idb Promise we created earlier. Now we can use the calculateGeoLocation anywhere in our application that we see fit. We can even run it on a interval or as a response to a user action. In my case, I usually ask for geolocation as part of the app on-boarding process tied to a user driven action.

This is how it looks like in the Chrome Application Tab
IndexDB table geolocations database, not creepy at all

Notes on Geolocation API

There are two main native web geolocation APIs available to use for developers. Geolocation.getCurrentLocation() and Geolocation.watchPosition(). We use Geolocation.watchPosition here as the latter supposedly has better accuracy for mobile devices and will update the position when device location changes. Both methods allow passing in a PositionOptions parameter to further customize for your specific needs.

For better accuracy set the PositionOptions.enabledHighAccuracy to true, but this might result in slower response times and drain device battery quicker.

Another useful option is PositionOptions.maximumAge which will be the amount of time in milliseconds the device is allowed to returned a cache geo position. Again, this depends on the specific use case and can save some calculations if the user location does not have to be super up-to-date or accurate.

Contrived Use Case Example

Now we want to combine Service Workers, Push Notification API, IndexedDB, and UserGeolocation together to build some useful feature for our mobile application. Imagine we have completely fictional mobile application that is to predict volcano eruptions in our cloud servers and administers a push notification to user devices when there is potential eruption. The push payload from backend will look like this.

{"data" : {
    "Lat" : 2.18,
    "Lon": 102.89,
    "Vei" : 6.8,
    "EruptionStartTime" : "2018-06-20 10:55:00.764269",
    "Region": ""},
    "type" : "SERIOUS"
}

The Lat and Lon is of the volcanic eruption coordinates. The EruptionStartTime is the future predicted eruption time by our servers. The VEI is the calculated intensity of this eruption.

Let's say the requirement is to have the progressive web app receive the push in the background and then decide the relative impact based on the users current location. In other words, should he run for his life or is it okay to just chill. One thing we need to do is calculate distance from EruptionPoint and user current location. We might also want to calculate how long they have until the heat waves and poisonous gases get to them. Then we want to show notification displaying all the super useful data.

Accessing IndexedDB in Push Event

Let us go back to the push listener event. We need to access our user stored location inside IndexedDB, make all our calculations, and then display to the user a relevant push message all within the push event.

// contrive use case example
self.addEventListener('push', function(event) {
    if (event.data) {
        const pushTitle = "Volcanic Alert";
        const pushOptions;
        const pushEventData = event.data.json();
        const eruptionPayload = JSON.parse(pushEventData.data);

        if (pushEventData.type === "SERIOUS") {
            // Access IndexDB for our geolocation data using the same
            // idb helper from earlier
            let notifResults = idbPromise().then(db => {

                let tx.db.transaction("userGeolocation", "readonly");
                let userGeolocation = tx.objectStore("userGeolocation");
                return userGeolocation.getAll();
            }).then(userGeolocationRecords => {

                const mostRecentGeolocation =
                    userGeolocationRecords[userGeolocationRecords.length - 1];

                // Calculate spherical distance from eruption
                const deviceSphericalDistanceFromEruption =
                    getSphericalDistance(
                        mostRecentGeolocation["Lat"],
                        mostRecentGeolocation["Lon"],
                        eruptionPayload["Lat"],
                        eruptionPayload["Lon"],
                    );

                // Calculate relative intensity
                const intensityReadableString = calculateIntensity(
                    deviceSphericalDistanceFromEruption,
                    eruptionPayload['Vei'],
                )

                // We might also want the relative time for impact to arrive
                const timeTillSeconds = getTimeTill(deviceSphericalDistanceFromEruption, eruptionPayload["EruptionStartTime"]);

                payload.intensityHumanReadable = intensityHumanReadable;
                payload.timeTillSeconds = timeTillSeconds;

                // Now return the calculate data from the promise
                return {
                    body: "Intensity: " + intensityReadableString,
                    tag: "alert",
                    data: payload,
                    renotify: true,
                    requireInteraction: true, // it's pretty important for him to see this
                }
            });

            // Now finish with event.waitUntil()
            event.waitUntil(
                ...
            )
        }

        // Handle other types here...
    }
});

Show Notification in event.waitUntil()

We know that the query to IndexDB is asynchronous and will return us a promise. This promise chain is exactly what event.waitUntil expects. event.waitUntil will wait until a promise is resolved and then show the notification, then terminate the service worker process. This is the trick to having indexedDB and Web Push work together in harmony. This part is super important to understand to avoid a ton of headache surrounding using service workers with asynchronous APIs.

// Finishing up with notification
  event.waitUntil(
    notifResults.then(notifOptions => {
      isClientFocused().then(clientOpts => {
          clientOpts.windowClients.forEach(windowClient => {
            windowClient.postMessage({
              message: 'Received a volcano alert.',
              time: new Date().toString(),
              intensity: notifOptions.data.intensityReadableString,
              seconds: notifOptions.data.timeTillSeconds,
            });
          });

        self.registration.showNotification(
          pushTitle, // Volcanic Alert
          notifOptions,
        )
      })
    })
  )
}

We should also handle the case where the user has the application already opened on his device for whatever reason. In this case, we should post the message from the Service Worker to the client app using an API like PostMessage illustrated above. I use a helper method isClientFocused() here which which will give me open clients if available. If you do not need to handle this case, then you can ignore that part and just show the notification directly.

Further Considerations

Always show a notification. Sometimes you will see Chrome display "This site has been updated in the background." The browser expects a notification to be shown and either a notification is not being shown after event.waitUntil() is finished or something is broken. Just check to see if self.registration.showNotification is being called and no errors are happening before that happens.

Handle the notification click. While not the subject of this post, make sure to consider what happens after the notification click event. Perhaps it takes them to the app url showing them a countdown of the eruption in our case.

Service Workers can be quite tricky. I have had my fair experience of mistakes and surprises over the months using Service Workers in production - sometimes with very high traffic sites. Perhaps I will write about it one day, but I always like to just link to this video: https://www.youtube.com/watch?v=CPP9ew4Co0M. As stated in the video, service workers can be a complex beast, so using a generator like sw-precache can be very helpful.

Making sure to test on Localhost. Service Worker installation requires HTTPs to prevent attacks such as man-in-the-middle, but localhost will work as an exception to that rule.

Communicating with your core app. Since the Service Worker acts as an proxy server separate from the web page, we need to use special methods to communicate between the two separate context. Two methods I have used in the past are PostMessage() and passing data through urls on notificationclick.

Hope the post was helpful, and let me know if you have any questions regarding similar implementations.

sources

https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API/Using_Service_Workers
https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API/Using_IndexedDB
https://geology.com/stories/13/volcanic-explosivity-index/

Posted on by:

adrienshen profile

adrienshen

@adrienshen

i build, write, and seek the truth.

Discussion

markdown guide