Making Your App Awesome When the Network Isn’t (Part 2)
A beginner’s guide to ensuring quick page loads — offline or not — with service workers
As I shared in the first article of this beginner-friendly series, the Offline First development approach plans for the most constrained network environment first, enabling a great user experience even while a device is offline or has only an intermittent connection, and providing progressive enhancement as network conditions improve. Even if your app isn’t specifically intended to be useful offline, the speed and performance delivered by an Offline First design pattern can greatly improve user satisfaction.
The typical offline experience
If you turned your internet off right now and tried to visit a URL you’d never visited before, you’d have a problem. It would look something like this:
If you travel in the right crowds, you might actually be excited to the see Chrome’s Downasaur because you know about the top-secret game hidden in plain sight. (Press the spacebar to start the game and jump over cacti, or the down arrow to duck under pterodactyls.) Most people, though, are just plain frustrated.
This failure to load is expected behavior for a first page visit, and even the best Offline First application can’t overcome it. The “first” in Offline First means that we’re keeping the offline user experience at the top of our priority list, not that we’re able to make a page work offline on first load.
Native applications cache vital resources during their installation process. As developers of web apps, we can mimic this process by using a modern web tool called service worker to cache the most critical page resources to a user’s device on their first visit to our page, keeping the core functionality of our app intact during subsequent visits. Yes, we may lose the ability to sync their data to a remote database or show them new articles that were posted since they lost their connection, but we can provide a positive user experience offline and then use progressive enhancement to add more functionality as network conditions improve.
This notion of progressive enhancement is critical to the design of all Offline First applications, which can range from web apps to native mobile apps, hybrid mobile apps, desktop apps, and Internet of Things apps. Progressive Web Apps — a subset of modern web apps that behave very much like native apps — rely heavily on service workers for both resource caching and push notifications, but here we’ll focus specifically on caching.
Introducing service worker
Current features enabled by service workers include push notifications, background sync, and caching, all of which can help a Progressive Web App behave more like a native app. Support for service workers is not yet universal, though. Chrome has a huge jump on other browsers who’ve joined the service worker bandwagon more recently. That means their developer advocacy team is a great source of documentation on service workers as applied to PWAs. The World Wide Web Consortium (W3C), which is responsible for the development of the service workers that browsers then incorporate, also offers a nice explainer.
The service worker lifecycle
Service workers have their own lifecycle that’s totally separate from your web page. It goes something like this:
- install (defining what to cache)
- be incredible (though also incredibly frustrating to debug)
Registering the service worker
Registering the service worker is the easiest step.
sw.js file has to be specifically called out in a script tag in our
index.html file in order to be put to use. After we load the other scripts we need (PouchDB, jQuery, and our own
project-manager.js), we first need to check to see whether the browser we’re using supports the service worker API. (If it does, there will be a
serviceWorker property present in
navigator, a property of
window which represents the browser.) If so, we can go ahead and register the service worker we’re about to create (our
sw.js file) and leave ourselves a note in the console for debugging purposes. Note that the
sw.js file lives at the top level of our project so that it will have access to fetch events for everything in our domain.
This step is easy, but not very useful without a service worker to register. Before we create our
sw.js file, though, we need to give some thought to what resources we need it to cache for us.
Deciding what to cache
As developers and UX professionals, we’re responsible for determining an acceptable amount of core functionality and data that will be accessible offline to our users (a minimum viable product, if you will), which we’ll then enhance with any extra goodies that can be added when a network connection is available. This is a delicate balancing game, though. In general, the more data and logic we can store on a user’s device instead of on a remote server, the more they can enjoy their experience when they’re disconnected or operating on a spotty connection. However, as we make more resources available locally, we’re tapping into the device’s storage space, computing power, and battery life. We have a responsibility to conserve resources as we decide what resources to cache.
For a news website, for example, you can imagine that it might be suitable to cache only the latest set of articles (or a set based on the reader’s interests) for offline access, versus all historical content of the publication. Many of the images on such a site might also fall in the nice-to-have category that would be added when a connection was restored. But being able to view a list of recent articles and select one to read is core functionality for that application, and needs to be supported offline.
In my own case, the main purpose of the app I’ve built is to allow an editor to keep track of the status of an article as it goes through the editorial process, and to see a list of all of the articles in the works. This happens through a main display of all articles and a form used to create and update records. So my first step in setting up caching is to determine which of my files are needed to make these features accessible.
Installing the service worker
We first need to add an
install event listener to the service worker (this
sw.js file, thus the
self in the snippet below). We then open a cache called
blog-tracker using the method
caches.open(), which either finds an existing cache by that name or creates a new one if none is found. Once the cache is open, we return
cache.addAll(), passing in an array of file names to add to the cache.
Note that we’re using promises here.
waitUntil() ensures a full block of code executes before the next step and
catch() deals with an error if the code within
then() won’t execute. If any of the files listed here fails to load, the service worker won’t be installed. The
install event happens only once, and a service worker can’t start handling network events for us until it finishes installing and becomes “active.” We also won’t see the effects of the service worker until we refresh our
In the Cache Storage pane, we can see that the cache named
blog-tracker contains all the files we asked the service worker to cache.
Activating the service worker
Once your service worker is controlling the page and ready to handle events, it will automatically activate. You don’t need to implement any code to make the service worker’s
activate event happen, but you can listen for it if you’d like to log a note to the console or make it trigger another function, such as making some updates to the cache (as described shortly). In my case, I never needed such code.
In Chrome dev tools, within the Service Workers pane of the Application tab, we can now see that our service worker is registered and activated.
Intercepting network requests
So our service worker is active and knows what to cache. But how do we use it to return those cached resources?
The Fetch API handles network requests for all of the resources in our application, but we can choose to hijack some of those network requests and use our service worker to respond to them. Back in our
sw.js file, we can listen for
fetch events within our service worker’s scope. When a fetch request comes in, we pass into
event.respondWith()a promise from
caches.match(), which attempts to find the requested resource in our cache. If it succeeds, it returns the matching cached resource. If it fails, it looks to the network for the resource, which will only work if there’s a connection.
In my case, all of my files are being cached, and none of them are changing, so I should never need the network fallback. However, if you have a more complex application, you’ll likely want more complex code here.
Offline First, not offline fallback
Remember, for an Offline First approach, it’s important that we always try to access cached resources first, using the network as a fallback, never vice versa. Why? If we first try the network and then the cache, we can actually provide a reasonably good experience both in good connectivity and offline. However, when connectivity is poor, we get stalled out waiting for the network to admit failure, and neither the network resource nor the cached resource is ever returned. This horrifically frustrating state, known as LieFi, is what you’re experiencing in those moments in which you swear at your phone and then ironically turn the WiFi off in order to force a page to load.
The Offline First approach, on the other hand, provides an incredibly fast user experience regardless of network conditions, because there’s always something to be loaded immediately, even if we’re hoping to replace it with a more updated version from the network. As we also saw when using PouchDB and CouchDB, serving data and resources locally first makes our app performant (speedy) regardless of our level of connectivity. Offline First isn’t a specialized approach for low bandwidth scenarios. It’s just a great design pattern.
Testing Offline First functionality
As mentioned in my previous article, the dev tools in both Chrome and Firefox offer ways to simulate being offline, so you can test offline functionality from your browser without disconnecting your whole computer from the network.
In fact, if you’ve built an app that syncs data between users, this is a great way to test what happens if one user goes offline. Just pretend Chrome is one user and Firefox is another.
Debugging with service workers
As you’re building a Progressive Web App, you may notice that changes to your code seem to have no effect on your project. As it turns out, service worker is so awesome at caching that it can trap your project in its current state. If you change an image file in your project, for example, and don’t stop your service worker, you might be looking at that old image forever. In fact, if you test a totally new project on the same port you tested the last one on, your service workers can even carry over from project to project. They’re sticky little buggers, but there are ways to get unstuck.
I learned very quickly that doing a standard browser refresh won’t cut it. Even without service workers installed, many modern browsers cache page resources to make your browsing experience better. As a developer, you’ll need to learn to do a hard refresh to clear the cache. (The commands used to force a hard refresh depend on your browser and operating system.) Even a hard refresh, though, in my own experience, can’t overcome a determined service worker.
For your own testing only, it can help to check the “Update on reload” box in the Application tab of your Chrome developer tools, which will force the browser to look for a new version of service worker (and the new set of files you used it to cache) each time you reload the page. Just remember that this is not what your user’s experience will be.
For more detailed tips on debugging apps with service workers, I recommend reading up on both Chrome DevTools and Firefox DevTools. The team at Google even offers a whole code lab on debugging service workers. Most of the tools you need for debugging are right in the Service Worker pane in the Application panel in the Chrome DevTools.
Summary & Resources
As we’ve seen, an Offline First approach to web development not only makes pages available offline, but also makes them load exceptionally quickly on a great network connection. Service worker makes it possible to cache the page resources you need to make this happen.
Remember, though, that you need a different solution to make your application’s data accessible offline, as we did with PouchDB in the previous article of this series.
There are some nuances of how Service Worker and PouchDB work together that are beyond my pay grade. Check out the collection of articles on Service Worker and PWAs to learn more.
And of course, don’t forget that service workers are capable of much more than the caching we’ve seen here. Push notifications are another awesome feature you might want to explore.
By using service worker for caching and PouchDB and CouchDB for data storage and sync, we’ve established the essentials that make our app work offline. It’s a web app with progressive enhancement, but it’s not technically a Progressive Web App yet. For the icing on the cake, we can earn that title by making it installable to the homescreen without the hassle of an app store. A web app manifest can make that possible.