TL;DR: Web development has evolved significantly over the years allowing developers to deploy a website or web application and serve millions of people around the globe within minutes. With just a browser, a user can put in a URL and access a web application. With, Progressive Web Apps, developers can deliver amazing app-like experiences to users using modern web technologies. In the first part of this tutorial we set up our progressive web app, cached the pages and made it work partially offline. This time, we'll make it load instantly and work offline fully.
Recap and Introduction to Part 2
In Introduction to Progressive Web Apps (Offline First), we discussed how a typical progressive web application should look like and also introduced the service worker. So far, we've cached the application shell. The
index
and latest
pages of our web app now load offline. They also load faster on repeated visits. Towards the end of the first part of this tutorial, we were able to load the latest
page offline but couldn't get dynamic data to display when the user is offline.This tutorial will cover:
- Caching the App data on the
page to be displayed to the user when offlinelatest
- Using
to store the App datalocalStorage
- Flushing out old App data and fetching updated data when the user is connected to the internet
Offline Storage
When building progressive web apps, there are various storage mechanisms to consider like so:
IndexedDB: This is a transactional JavaScript based database system for client-side storage of data. This database employs the use of indexes to enable high performance searches of the data stored in it. IndexedDB exposes an asynchronous API that supposedly avoids blocking the DOM, but some research has shown that in some cases it is blocking. I recommend that you use libraries when working with IndexedDB because manipulating it in vanilla JavaScript can be very verbose and complex. Examples of good libraries are localForage, idb and idb-keyval.
IndexedDB browser support
- Cache API: This is best for storing url addressable resources. Works with Service worker really well.
- PouchDB: Open Source JavaScript database inspired by CouchDB. It enables applications to store data locally while offline, then synchronize it with CouchDB and compatible servers when the application is back online, keeping the user's data in sync no matter where they next login. PouchDB supports all modern browsers, using IndexedDB under the hood and falling back to WebSQL where IndexedDB isn't supported. Supported in Firefox 29+ (Including Firefox OS and Firefox for Android), Chrome 30+, Safari 5+, Internet Explorer 10+, Opera 21+, Android 4.0+, iOS 7.1+ and Windows Phone 8+.
Web Storage e.g localStorage: It is synchronous and can block the DOM. The usage is capped at 5MB in most browsers. It has a simple API for storing data and it uses key-value pairs.
Web Storage browser support
WebSQL: This is a relational database solution for browsers. It has been deprecated and the specification is no longer maintained. So, browsers may not support it in the future.
Mobile Storage Quota
Addy Osmani has a comprehensive resource on Offline storage for progressive web apps. You should really check it out!
According to PouchDB maintainer, Nolan Lawson, do well to ask yourself these questions when you are using a database:
- Is this database in-memory or on-disk(PouchDB, IndexedDB)?
- What needs to be stored on disk? What data should survive the application being closed or crashing?
- What needs to be indexed in order to perform fast queries? Can I use an in-memory index instead of going to disk?
- How should I structure my in-memory data relative to my database data? What’s my strategy for mapping between the two?
- What are the query needs of my app? Does a summary view really need to fetch the full data, or can it just fetch the little bit it needs? Can I lazy-load anything?
You can check out how to think about databases to give you a broader knowledge on the subject matter.
Let's Implement Instant Loading
For our web app, we'll use
localStorage
. I recommend that you don't use localStorage
for production apps because of the limitations I highlighted earlier in this tutorial. The app we are building is a very simple one, so localStorage
will work fine.Open up your
js/latest.js
file. We will update the fetchCommits
function to store the data it fetches from the Github API in localStorage
like so:function fetchCommits() { var url = 'https://api.github.com/repos/unicodeveloper/resources-i-like/commits'; fetch(url) .then(function(fetchResponse){ return fetchResponse.json(); }) .then(function(response) { console.log("Response from Github", response); var commitData = {}; for (var i = 0; i < posData.length; i++) { commitData[posData[i]] = { message: response[i].commit.message, author: response[i].commit.author.name, time: response[i].commit.author.date, link: response[i].html_url }; } localStorage.setItem('commitData', JSON.stringify(commitData)); for (var i = 0; i < commitContainer.length; i++) { container.querySelector("" + commitContainer[i]).innerHTML = "<h4> Message: " + response[i].commit.message + "</h4>" + "<h4> Author: " + response[i].commit.author.name + "</h4>" + "<h4> Time committed: " + (new Date(response[i].commit.author.date)).toUTCString() + "</h4>" + "<h4>" + "<a href='" + response[i].html_url + "'>Click me to see more!</a>" + "</h4>"; } app.spinner.setAttribute('hidden', true); // hide spinner }) .catch(function (error) { console.error(error); }); };
With this piece of code above, on first page load, the commit data will be stored in
localStorage
. Now let's write another function to retrieve the data from localStorage
like so:// Get the commits Data from the Web Storage function fetchCommitsFromLocalStorage(data) { var localData = JSON.parse(data); app.spinner.setAttribute('hidden', true); //hide spinner for (var i = 0; i < commitContainer.length; i++) { container.querySelector("" + commitContainer[i]).innerHTML = "<h4> Message: " + localData[posData[i]].message + "</h4>" + "<h4> Author: " + localData[posData[i]].author + "</h4>" + "<h4> Time committed: " + (new Date(localData[posData[i]].time)).toUTCString() + "</h4>" + "<h4>" + "<a href='" + localData[posData[i]].link + "'>Click me to see more!</a>" + "</h4>"; } };
This piece of code fetches data from
localStorage
and appends it to the DOM. Now, we need a conditional to know when to call the
fetchCommits
and fetchCommitsFromLocalStorage
function. The updated
latest.js
file should look like so:latest.js
(function() { 'use strict'; var app = { spinner: document.querySelector('.loader') }; var container = document.querySelector('.container'); var commitContainer = ['.first', '.second', '.third', '.fourth', '.fifth']; var posData = ['first', 'second', 'third', 'fourth', 'fifth']; // Check that localStorage is both supported and available function storageAvailable(type) { try { var storage = window[type], x = '__storage_test__'; storage.setItem(x, x); storage.removeItem(x); return true; } catch(e) { return false; } } // Get Commit Data from Github API function fetchCommits() { var url = 'https://api.github.com/repos/unicodeveloper/resources-i-like/commits'; fetch(url) .then(function(fetchResponse){ return fetchResponse.json(); }) .then(function(response) { console.log("Response from Github", response); var commitData = {}; for (var i = 0; i < posData.length; i++) { commitData[posData[i]] = { message: response[i].commit.message, author: response[i].commit.author.name, time: response[i].commit.author.date, link: response[i].html_url }; } localStorage.setItem('commitData', JSON.stringify(commitData)); for (var i = 0; i < commitContainer.length; i++) { container.querySelector("" + commitContainer[i]).innerHTML = "<h4> Message: " + response[i].commit.message + "</h4>" + "<h4> Author: " + response[i].commit.author.name + "</h4>" + "<h4> Time committed: " + (new Date(response[i].commit.author.date)).toUTCString() + "</h4>" + "<h4>" + "<a href='" + response[i].html_url + "'>Click me to see more!</a>" + "</h4>"; } app.spinner.setAttribute('hidden', true); // hide spinner }) .catch(function (error) { console.error(error); }); }; // Get the commits Data from the Web Storage function fetchCommitsFromLocalStorage(data) { var localData = JSON.parse(data); app.spinner.setAttribute('hidden', true); //hide spinner for (var i = 0; i < commitContainer.length; i++) { container.querySelector("" + commitContainer[i]).innerHTML = "<h4> Message: " + localData[posData[i]].message + "</h4>" + "<h4> Author: " + localData[posData[i]].author + "</h4>" + "<h4> Time committed: " + (new Date(localData[posData[i]].time)).toUTCString() + "</h4>" + "<h4>" + "<a href='" + localData[posData[i]].link + "'>Click me to see more!</a>" + "</h4>"; } }; if (storageAvailable('localStorage')) { if (localStorage.getItem('commitData') === null) { /* The user is using the app for the first time, or the user has not * saved any commit data, so show the user some fake data. */ fetchCommits(); console.log("Fetch from API"); } else { fetchCommitsFromLocalStorage(localStorage.getItem('commitData')); console.log("Fetch from Local Storage"); } } else { toast("We can't cache your app data yet.."); } })();
In the piece of code above, we are checking if the browser supports
localStorage
and if it does, we go ahead to check if the commit data has been cached. If it has not been cached, we fetch, display and cache the app's commit data.Now, reload the browser again, make sure you do a hard, clear-cache, reload else we won't see the result of our code changes.
Now, go offline and load the
latest
page. What happens?Yaaay!! it loads the data without any problem.
Check the DevTools, you'll see the data been stored in
localStorage
.Store Data Locally
Just look at the speed at which it loads when the user is offline....OMG!!!
Loads from Service Worker when user is offline
One More Thing
Now, we can make the app load instantly by fetching data from the localStorage. How do we get fresh updated data? We need a way of still getting fresh data especially when the user is online.
It's simple. Let's add a refresh button that triggers a request to GitHub to get the most recent data.
Open up your
latest.html
file and add this code for the refresh button within the <header>
tag.<button id="butRefresh" class="headerButton" aria-label="Refresh"></button>
So the
<header>
tag should look like this after adding the button:<header> <span class="header__icon"> <svg class="menu__icon no--select" width="24px" height="24px" viewBox="0 0 48 48" fill="#fff"> <path d="M6 36h36v-4H6v4zm0-10h36v-4H6v4zm0-14v4h36v-4H6z"></path> </svg> </span> <span class="header__title no--select">PWA - Commits</span> <button id="butRefresh" class="headerButton" aria-label="Refresh"></button> </header>
Finally, let's attach a
click
event to the button and add functionality to it. Open your latest.js
and add this code at the top like so:document.getElementById('butRefresh').addEventListener('click', function() { // Get fresh, updated data from GitHub whenever you are clicked toast('Fetching latest data...'); fetchCommits(); console.log("Getting fresh data!!!"); });
Clear your cache and reload the app. Now, your
latest.html
page should look like so:Get Updated data
Anytime users need the most recent data, they can just click on the refresh button.
Aside: Easy Authentication with Auth0
You can use Auth0 Lock for your progressive web app. With Lock, showing a login screen is as simple as including the auth0-lock library and then calling it in your app like so:
// Initiating our Auth0Lock var lock = new Auth0Lock( 'YOUR_CLIENT_ID', 'YOUR_AUTH0_DOMAIN' ); // Listening for the authenticated event lock.on("authenticated", function(authResult) { // Use the token in authResult to getProfile() and save it to localStorage lock.getProfile(authResult.idToken, function(error, profile) { if (error) { // Handle error return; } localStorage.setItem('idToken', authResult.idToken); localStorage.setItem('profile', JSON.stringify(profile)); }); });
Note: If you want to use Auth0 authentication to authorize API requests, note that you'll need to use a different flow depending on your use case. Auth0
idToken
should only be used on the client-side. Access tokens should be used to authorize APIs. You can read more about making API calls with Auth0 here.Implementing Lock
document.getElementById('btn-login').addEventListener('click', function() { lock.show(); });
Showing Lock
Auth0 Lock Screen
In the case of an offline-first app, authenticating the user against a remote database won't be possible when network connectivity is lost. However, with service workers, you have full control over which pages and scripts are loaded when the user is offline. This means you can configure your
offline.html
file to display a useful message stating the user needs to regain connectivity to login again instead of displaying the Lock login screen.Conclusion
In this article, we were able to make our app load instantly and work offline. We were able to cache our dynamic data and serve the user the cached data when offline.
In the final part of this tutorial, we will cover how to enable push notifications, add web application manifest and our app to a user's homescreen.