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).
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.
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:
- The Service Worker Primer on Web Fundamentals
- A Web Fundamentals codelab on your first offline webapp
- A course on Offline Web Apps with Service Worker over on Udacity
- Jake Archibald’s offline cookbook is another excellent resource we recommend reading through.
- Progressive Web Apps with Webpack is another good guide for learning how to get offline caching working with mostly vanilla Service Worker code (if you prefer not to use a library).
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
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.
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.
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.
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:
- Using vanilla Service Worker code. A number of samples with different caching strategies are available in the GoogleChrome samples repo and Jake Archibald’s Offline Cookbook.
- Using sw-precache and sw-toolbox via a one-liner in your package.json script field. ReactHN does this
- Using a plugin for your Webpack setup like sw-precache-webpack-plugin or offline-plugin. Starter kits like react-boilerplate include it by default.
- Using create-react-app and our Service Worker libraries to add offline caching support in just a few lines (similar to the above).
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"
]
}
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.
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:
- How do I remove a buggy service worker or implement a kill switch?
- What are my options for testing my service worker code?
- Can service workers cache POST requests?
- How do I prevent the same sw from registering over multiple pages?
- Can I read cookies from inside a service worker? (not yet, coming)
- How does global error handling work in service workers?
Other resources
- Is Service Worker Ready? — browser implementation status & resources
- Instant Loading: Building offline-first Progressive Web Apps — Jake
- Offline Support for Progressive Web Apps — Totally Tooling Tips
- Instant Loading with Service Workers — Jeff Posnick
- The Mozilla Service Worker Cookbook
- Getting started with Service Worker Toolbox — Dean Hume
- Resources on unit testing Service Workers — Matt Gaunt
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.