Progressive Web Apps with React.js: Part 3 — Offline support and network resilience

Part 3 of a new series walking through tips for shipping mobile web apps optimized using Lighthouse. This issue, we’ll be looking at making your React apps work offline.

A good Progressive Web App loads instantly regardless of network state and puts up its own UI on the screen without requiring a network round trip (i.e when it’s offline).

Repeat visits to the Housing.com Progressive Web App (built with React and Redux) instantly load their offline-cached UI.

We can accomplish this using service worker. A service worker is a background worker that acts as a programmable proxy, allowing us to control what happens on a request-by-request basis. We can use it to make (parts of, or even entire) React apps work offline.

You’re in control of how much of your UX is available offline. You can offline-cache just the application shell, all of the data (like ReactHN does for stories) or offer a limited, but useful set of stale data like Housing.com and Flipkart do. Both indicate offline by graying out their UIs so you know “live” prices may be different.

Service workers depend on two APIs to work effectively: Fetch (a standard way to retrieve content from the network) and Cache (content storage for application data. This cache is independent from the browser cache or network status).

Note: Service workers can be applied as a progressive enhancement. Although browser support continues to improve, users without support for the feature can still fully use your PWA as long as they are connected to the network.

A base for advanced features

Service workers are also designed to work as a bedrock API for unlocking features that enable web apps to work more like native apps. This includes:

  • Push API — An API enabling push services to send push messages to a webapp. Servers can send messages at any time, even when the webapp or browser are not running.
  • Background Sync — for deferring actions until the user has stable connectivity. This is handy for making use whatever the user wants to send is actually sent. This enables pushing periodic updates when the app is next online.

Service Worker Lifecycle

Each service worker goes through three steps in its lifecycle: registration, installation and activation. Jake Archibald covers them in more depth here.

Registration

To install a service worker, you need to register it in script. Registration informs the browser where your service worker is located and lets it know it can start installing in the background. Basic registration in your index.html could look like this:

// Check for browser support of service worker
if ('serviceWorker' in navigator) {
 navigator.serviceWorker.register('service-worker.js')
.then(function(registration) {
// Successful registration
console.log('Hooray. Registration successful, scope is:', registration.scope);
}).catch(function(err) {
// Failed registration, service worker won’t be installed
console.log('Whoops. Service worker registration failed, error:', error);
});
}

The service worker is registered with navigator.serviceWorker.register which returns a Promise that resolves when the SW has been successfully registered. The scope of the service worker is logged with registration.scope.

Scope

The scope of the service worker determines from which path it will intercept requests. The default scope is the path to the service worker file. If service-worker.js is located in the root directory, the service worker will control requests from all files at the domain. You can set an arbitrary scope by passing an extra parameter while registering:

navigator.serviceWorker.register('service-worker.js', {
scope: '/app/'
});

Installation and activation

Service workers are event driven. The installation and activation processes fire off corresponding install and activate events to which the service workers can respond.

With the service worker registered, the first time a user hits your PWA, the install event will be triggered and this is where you’ll want to cache the static assets for the page. This happens if the service worker is considered new, either because this is the first service worker encountered for the page or there’s a byte-difference between the current service worker and the previously installed one. install is the point when you can cache anything before getting a chance to control clients.

We could add very basic caching to a static app using the following code:

var CACHE_NAME = 'my-pwa-cache-v1';
var urlsToCache = [
'/',
'/styles/styles.css',
'/script/webpack-bundle.js'
];
self.addEventListener('install', function(event) {
event.waitUntil(
caches.open(CACHE_NAME)
.then(function(cache) {
// Open a cache and cache our files
return cache.addAll(urlsToCache);
})
);
});

addAll() takes an array of URLs, requests, fetches them and adds them to the cache. If any fetch/write fails, the op fails and the cache gets returned to its last state.

Intercepting and caching requests

When a service worker controls a page, it can intercept each request being made by the page and decide what to do with it. This makes it a lot like a background proxy. We can use this to intercept requests to our urlsToCache and return the locally cached versions of assets instead of having to go back to the network. We can do this by attaching a handler to the fetch event:

self.addEventListener('fetch', function(event) {
console.log(event.request.url);
event.respondWith(
caches.match(event.request).then(function(response) {
return response || fetch(event.request);
})
);
});

In our fetch listener (specifically, event.respondWith), we pass along a promise from caches.match() which looks at the request and will find any cached results from entries the service worker created. If there’s a matching response, the cached value is returned.

That’s it. A number of free sources are available for learning about Service Worker:

A third-party API wishing to deploy their own service worker that could handle requests made by other origins to their origin can enable this using Foreign Fetch. This is useful for custom networking logic & defining a single cache instance for responses.

Dipping your toe in the water — custom offline pages

The React-based mobile.twitter.com use a Service Worker to serve a custom offline page when the network can’t be reached.

Providing users a meaningful offline experience (e.g readable content) is a great goal. That said, early on in your service worker experimentation you may find getting a custom offline page setup is a small step in the right direction. There are good samples available demonstrating how to accomplish this.

Lighthouse

If your app meets the below Lighthouse conditions for having a sufficient experience when offline, you’ll get a full pass.

The start_url check is useful for checking the experience users have launching your PWA from the homescreen when offline is definitely in the cache. This catches lots of folks out, so just make sure it’s listed in your Web App manifest.

Chrome DevTools

DevTools supports debugging Service Workers & emulating offline connectivity via the Application tab.

I also strongly recommend developing with 3G throttling (and CPU throttling via the Timeline panel) enabled to emulate how well your app works with offline and flaky network connections on lower-end hardware.

The Application Shell Architecture

An application shell (or app shell) architecture is one way to build a Progressive Web App that reliably and instantly loads on your users’s screens, similar to what you see in native applications.

The app “shell” is the minimal HTML, CSS and JavaScript required to power the user interface (think toolbars, drawers and so on) and when cached offline can ensure instant, reliably good performance to users on repeat visits. This means the application shell does not need to be loaded each time, but instead only gets the necessary content it needs from the network.

Housing.com use an AppShell with placeholders for content. This is nice for improving perceived performance as content fills in these holders once fully loaded.

For single-page applications with JavaScript-heavy architectures, an application shell is a go-to approach. This approach relies on aggressively caching the shell (using Service Worker) to get the application running. Next, the dynamic content loads for each page using JavaScript. An app shell is useful for getting some initial HTML to the screen fast without a network. Your shell may be using Material UI or more likely your own custom styles.

Note: Try the First Progressive Web App codelab to learn how to architect and implement your first application shell for a weather app. The Instant Loading with the App Shell model video also walks through this pattern.

We cache the shell offline using the Cache Storage API (via service worker) so that on repeat visits, the app shell can be loaded instantly so you get meaningful pixels on the screen really fast without the network, even if your content eventually comes from there.

Note that you can ship a PWA using a simpler SSR or SPA architecture, it just won’t have the same performance benefits and will instead rely more on full-page caching.

Low-friction caching with Service Worker Libraries

Two libraries were developed to handle two different offline use cases: sw-precache to automate precaching of static resources, and sw-toolbox to handle runtime caching and fallback strategies. The libraries complement each other nicely, and allowed us to implement a performant strategy in which a static content “shell” is always served directly from the cache, and dynamic or remote resources are served from the network, with fallbacks to cached or static responses when needed.

AppShell caching: Our static resources (HTML, JavaScript, CSS, and images) provide the core shell for the web application. Sw-precache allows us to make sure that most of these static resources are cached, and that they are kept up to date. Precaching every resource that a site needs to work offline isn’t feasible.

Runtime caching: Some resources are too large or infrequently used to make it worthwhile, and other resources are dynamic, like the responses from a remote API or service. But just because a request isn’t precached doesn’t mean it has to result in a NetworkError. sw-toolbox gives us the flexibility to implement request handlers that handle runtime caching for some resources and custom fallbacks for others.

sw-toolbox supports a number of different caching strategies including network-first (ensure the freshest data is used if available, but fall back to the cache), cache-first (check a request matches a cache entry, fallback to the network), fastest (request resources from both the cache and network at the same time, respond with whichever returns first). It’s important to understand the pros and cons of these approaches.

sw-toolbox & sw-precache were used to power the offline caching in Progressive Web Apps by Housing.com, the NFL, Flipkart, Alibaba, the Washington Post and numerous other sites. That said, we’re always interested in feedback and how we can improve them.

Offline caching for a React app

Using Service Worker and the Cache Storage API to cache URL addressable content can be accomplished in a few different ways:

Talks discussing how to use these SW libraries to build a React app are also available:

sw-precache vs offline-plugin

As mentioned, offline-plugin is another library for adding Service Worker caching to your pages. It was designed with minimal configuration in mind (it aims for zero) and deeply integrates with Webpack, knowing when publicPath is used and can automatically generate relativePaths for caches without any config needing to be specified. For static sites, offline-plugin is a good alternative to sw-precache. If you’re using HtmlWebpackPlugin, offline-plugin will also cache .html pages.

module.exports = {
plugins: [
// ... other plugins
new OfflinePlugin()
]
}

I cover offline storage strategies for other types of data in Offline Storage for Progressive Web Apps. Specific to React, if you’re looking to add caching for your data stores and are using Redux you may be interested in Redux Persist or Redux Replicate LocalForage (the latter is ~8KB gzipped).

Mini case-study: Adding offline caching to ReactHN

ReactHN started out as an SPA without offline support. We added this in a few phases:

Step 1: Offline caching the static resources for the application “shell” using sw-precache. By calling the sw-precache CLI from our package.json’s “scripts” field, we generated a Service Worker for precaching the shell each time the build completes:

"precache": "sw-precache — root=public — config=sw-precache-config.json"

The precache config file passed through above gives us control over what files and helper scripts are imported:

{
"staticFileGlobs": [
"app/css/**.css",
"app/**.html",
"app/js/**.js",
"app/images/**.*"
],
"verbose": true,
"importScripts": [
"sw-toolbox.js",
"runtime-caching.js"
]
}
sw-precache lists the total size of static assets that will be cached offline in its output. This is helpful for understanding just how large your application shell and the resources needed for it to become interactive are.

Note: If we were starting this today, I would just use the sw-precache-webpack-plugin which can be configured directly from your normal Webpack config:

plugins: [
new SWPrecacheWebpackPlugin(
{
cacheId: "react-hn",
filename: "my-service-worker.js",
staticFileGlobs: [
"app/css/**.css",
"app/**.html",
"app/js/**.js",
"app/images/**.*"
],
verbose: true
}
),

Step 2: We also wanted to cache runtime/dynamic requests. For this we imported in sw-toolbox and our runtime-caching config above. Our app was using Web Fonts from Google Fonts, so we added a simple rule to cache anything coming back from the fonts subdomain on google.com:

global.toolbox.router.get('/(.+)', global.toolbox.fastest, {
origin: /https?:\/\/fonts.+/
});

To cache requests for data from an API endpoint (e.g an AppEngine on appspot.com), it’s pretty similar:

global.toolbox.router.get('/(.*)', global.toolbox.fastest, {
origin: /\.(?:appspot)\.com$/
})

Note: sw-toolbox supports a number of useful options, including the ability to set maximum age for cached entries (via maxAgeSeconds). For more details on what’s supported, read the API docs.

Step 3: Take time to think about what the most useful offline experience is for your users. Every app is different.

ReactHN relies on real-time data from Firebase for stories & comments. After much experimentation, we found a healthy balance of UX and performance was in offering an offline experience with slightly stale data.

There’s much to learn from other PWAs that have already shipped so I encourage researching & sharing learnings as much as possible ❤

Offline Google Analytics

Once you have Service Worker powering the offline experience in your PWA you might turn your gaze to other concerns, like making sure Google Analytics work while you’re offline. Normally, if you try getting GA to work offline, those requests will fail and you won’t get any meaningful data logged.

offline Google Analytics events queued up in IndexedDB

We can fix this using the Offline Google Analytics library (sw-offline-google-analytics). It queues up any GA requests while a user is offline and retries them later once the network is available again. We successfully used a similar technique in this year’s Google I/O web app and encourage folks give it a try.

Frequently asked questions (and answers)

For me, the trickiest part of getting Service Worker right has always been the debugging. This has become significantly easier in Chrome DevTools over the last year and I strongly recommend doing the SW debugging codelab to save yourself some time and tears later on :)

You might also find documenting what you find tricky (or new) helps others. Rich Harris did this in stuff I wish I’d known sooner about service workers.

For everything else, SO has been a great source of answers:

Other resources

and that’s a wrap!

In part 4 of this series, we look at enabling progressive enhancement for React.js based Progressive Web Apps using universal rendering.

If you’re new to React, I’ve found React for Beginners by Wes Bos excellent.

With thanks to Gray Norton, Sean Larkin, Sunil Pai, Max Stoiber, Simon Boudrias, Kyle Mathews, Arthur Stolyar and Owen Campbell-Moore for their reviews.