Jump to content
Slate Blackcurrant Watermelon Strawberry Orange Banana Apple Emerald Chocolate Marble
Slate Blackcurrant Watermelon Strawberry Orange Banana Apple Emerald Chocolate Marble
Sign in to follow this  
Rss Bot

Make your app work offline with Service Workers

Recommended Posts

Service Workers can be used to improve loading times and offline support for your sites and web apps. In this tutorial we're going to show you how to progressively enhance a web app with a Service Worker. First we'll cover what is a Service Worker and how its lifecycle works, then we'll show you how to use then to speed up your site (this page) and offer offline content (page 2). 

Then we'll show you how to how to build an app with Service Workers. You'll learn how to set up a bare-bones Worker that will cache and serve static assets (delivering a huge performance boost on subsequent loads), then how to cache dynamic API responses and give our demo app full offline support. First, let's look at what exactly Service Workers are, and how they function.

What is a Service Worker?

So what is a Service Worker? It's a script, written in JavaScript, that your browser runs in the background. It doesn't affect the main thread (where JavaScript usually runs on a web page), and won't conflict with your app code or affect the runtime performance. 

A Service Worker doesn't have direct access to the DOM or events and user interaction happening in the web page itself. Think of it as a layer that sits between the web page and the network, allowing it to intercept and manipulate network requests (e.g. Ajax requests) made by your page. This makes it ideal for managing caches and supporting offline usage.

The Service Worker lifecycle

The life of a Service Worker follows a simple flow, but it can be a bit confusing when you're used to JS scripts just working immediately: 

Installing > Waiting (installed) > Activating > Activated > Redundant

When your page is first loaded, the registration code we added to index.html starts the installation of the Service Worker. When there is no existing Worker the new Service Worker will be activated immediately after installation. A web page can only have one Service Worker active at a time.

If a Worker is already installed, the new Service Worker will be installed and then sit at the waiting step until the page is fully closed and then reloaded. Simply refreshing is not enough because you might have other tabs open. You need to ensure all instances of the page are closed otherwise the new Worker won't activate. You don't have to close the tabs, you can just navigate away to another site and return.

Both install and activate events will only occur once per worker. Once activated, the Service Worker will then have control of the page and can start handling events such as fetch to manipulate requests.

Finally a Service Worker will become redundant if the browser detects that the worker file itself has been updated or if the install or activation fail. The browser will look for a byte difference to determine if a worker script has been updated.

It's important to note you should never change (or rev) the name of your Service Worker. Nor should you cache the worker file itself on the server, as you won't be able to update it easily, though browsers are now smart enough to ignore caching headers.

01. Clone the demo app

Okay, let's get started learning how to build a web app with help from Service Workers. For this tutorial, you're going to need recent versions of Node.js and npm installed on your computer.

We've knocked up a demo app that we will use as the basis for this tutorial (clone the demo app here). The app is a fun little project that fetches the five-day weather forecast based on the user's location. It'll then check if rain is forecast before the end of the day and update the UI accordingly.

It has been built inefficiently (intentionally) using large, unnecessary libraries such as jQuery and Bootstrap, with big unoptimised images to demonstrate the difference in performance when using a Service Worker. It currently weighs in at a ridiculous 4.1MB.

02. Get your API key

In order to fetch the weather data from the API you will need to get yourself a free API key from OpenWeatherMap:

Once you've got your key, open up index.html and look for the window.API_KEY variable in the <head>. Paste your key into the value:

03. Start the development server

Now we're ready to start working on the project. First of all let's install the dependencies by running:

There are two tasks for the build tool. Run npm start to start the development server on port 3000. Run npm run build to prepare the 'production' version. Bear in mind that this is only a demo, so isn't really a production version – there's no minification or anything – the files just get 'revved'.

An algorithm is used to create a hash, such as 9c616053e5, from the file's contents. The algorithm will always output the same hash for the same contents, meaning that as long as you don't modify the file, the hash won't change. The hash is then appended to the filename, so for example styles.css might become styles-9c616053e5.css. The hash represents the file's revision – hence 'revved'.

You can safely cache each revision of the file on your server without ever having to invalidate your cache, which is expensive, or worry about some other third-party cache serving up the incorrect version.

04. Introduce your Service Worker

Now let's get started with our Service Worker. Create a file called sw.js in the root of the src directory. Then add these two event listeners to log the install and activate events:

The self variable here represents the Service Worker's global read-only scope. It's a bit like the window object in a web page.

Next we need to update our index.html file and add the commands to install the Service Worker. Add this script just before the closing </body> tag. It will register our worker and log its current status.

Start your development server by running npm start and open the page in a modern browser. We'd recommend using Google Chrome as it has good service-worker support in its DevTools, which we'll be referring to throughout this tutorial. You should see three things logged to your Console; two from the Service Worker for the install and activate events, and the other will be the message from the registration.

05. Activate the Worker

We're going to tell our worker to skip the waiting step and activate now. Open the sw.js file and add this line anywhere inside the install event listener:

Now, when we update the Worker script, it will take control of the page immediately after installation. It's worth bearing in mind that this can mean the new Worker will be taking control of a page that may have been loaded by a previous version of your Worker – if that is going to cause problems, don't use this option in your app.

You can confirm this by navigating away from the page and then returning. You should see the install and activate events fire again when the new Worker has been installed.

Chrome DevTools has a helpful option that means you can update your Worker just by reloading. Open DevTools and go to the Application tab, then choose Service Worker from the left column. At the top of the panel is a tick box labelled Update on reload, tick it. Your updated Worker will now be installed and activated on refresh.

06. Confirm changes

Let's confirm this by adding console.log('foo') call in either of the event listeners and refreshing the page. This caught us out because we were expecting to see the log in the console when we refreshed, but all we were seeing was the 'SW activated' message. It turns out Chrome refreshes the page twice when the Update on reload option is ticked. 

You can confirm this by ticking the Preserve log tick box in the Console settings panel and refreshing again. You should see the install and activate events logged, along with 'foo', followed by 'Navigated to http://localhost:3000/' to indicate that the page was reloaded and then then final 'SW activated' message.

07. Track the fetch event

Time to add another listener. This time we'll track the fetch event that is fired every time the page loads a resource, such as a CSS file, image or even API response. We'll open a cache, return the request response to the page and then – in the background – cache the response. First off let's add the listener and refresh so you can see what happens. In the console you should see many FetchEvent logs.

Our serve mode uses BrowserSync, which adds its own script to the page and makes websocket requests. You'll see the FetchEvents for these too, but we want to ignore these. We also only want to cache GET requests from our own domain. So let's add a few things to ignore unwanted requests, including explicitly ignoring the / index path:

Now the logs should be much cleaner and it is safe to start caching.

08. Cache the assets

Now we can start caching these responses. First we need to give our cache a name. Let's call ours v1-assets. Add this line to the top of the sw.js file:

Then we need to hijack the FetchEvents so we can control what is returned to the page. We can do that using the event's respondWith method. This method accepts a Promise so we can add this code, replacing the console.log:

This will forward the request on to the network then store the response in the cache, before sending the original response back to the page.

It is worth noting here that this approach won't actually cache the responses until the second time the user loads the page. The first time will install and activate the worker, but by the time the fetch listener is ready, everything will have already been requested.

Refresh a couple of times and check the cache in the DevTools > Application tab. Expand the Cache Storage tree in the left column and you should see your cache with all the stored responses.

09. Serve from the cache

Everything is cached but we're not actually using the cache to serve any files just yet. Let's hook that up now. First we'll look for a match for the request in the cache and if it exists we'll serve that. If it doesn't exist, we'll use the network and then cache the response.

Save the file and refresh. Check DevTools > Network tab and you should see (from ServiceWorker) listed in the Size column for each of the static assets. 

Phew, we're done. For such a small amount of code, there's a lot to understand. You should see that refreshing the page once all assets are cached is quite snappy but let's do a quick (unscientific) check of load times on a throttled connection (DevTools > Network tab). 

Without the Service Worker, loading over a simulated fast 3G network takes almost 30 seconds for everything to load. With the Service Worker, with the same throttled connection but loading from the cache, it takes just under a second.

Check the Offline box and refresh and you'll also see that the page loads without a connection, although we can't get the forecast data from the API. On page 2 we'll return to this and learn how to cache the API response too. 

Next page: use Service Worker to offer online access

10. Cache dynamic responses

Now we're going to further enhance our worker to cache the dynamic API response, learn about caching strategies and give our app full offline support. 

As it stands we're only caching static assets, such as image and JS libraries. Service Workers enable us to cache dynamic responses, such as API responses, but we need to put some thought into it first. And if we want to give our app offline support, we'll also need to cache the 'index.html' file, too.

We've got a few options to choose from for our caching strategy: 

  • Cache only
  • Network only
  • Network first, falling back to cache
  • Cache first, falling back to network
  • Cache then network

Each has its pros and cons. There is an excellent Google article in the Further Reading section that explains each approach and how to implement it.

The code we added above for the static assets uses the cache first, then falls back to the network approach. We can safely do this because our static assets are 'revved'. We need to decide what's best for our dynamic API response, though. 

The answer depends on the data returned by the server, how critical fresh data is to your users and how frequently you call the endpoint. If the data is likely to change frequently or if it is critical that is it up-to-date then we don't want to be serving stale data from our cache by default. However if you are going to be polling the endpoint every 10 seconds, say, then perhaps cache-first is more suitable and you can update the cache in the background in preparation for the next request. 

11. Consider user-specific responses

The other consideration with caching API responses is user-specific responses. If your app enables users to login then you need to remember that multiple users may use the same computer. You don't want to be serving a cached user profile to a different user!

In our scenario we can assume that the response from the API will be changing frequently. For a start it is responding with the forecast for the next 120 hours (five days), in three hour chunks, meaning if we call it again in three hours' time we will get a different response than we get now. And, of course, this is weather forecast data so at least here in the UK it will be changing all the time. For that reason let's go for network first, then fall back to cache. The user will get the cached response only if the network request fails, perhaps because they are offline. 

12. Cache the index

This is also a safe approach for our 'index.html' file so we'll include that in the cache, too. Remember you don't want to end up with users stuck on a stale version of your app (in their cache) because you've cached everything too aggressively. Another option here is to change the cache name, that is 'v1-assets' becomes 'v2-assets', with each new release but this approach has additional overhead because you need to add code to manually clean up the old caches. For the purposes of this tutorial we'll take the simpler option!

13. Add another fetch listener

Currently our existing fetch listener looks for a match for a request in all caches but it always follows the cache-first approach. We could modify this to switch modes but we'd end up with an unwieldy listener. Instead, just as you can with normal JS, we'll simply add another fetch listener. One will handle the assets cache and the other will handle the dynamic cache.

We need to include some of the same checks to filter out unwanted requests, then we want to allow certain requests to be cached. Add this new listener below your existing fetch listener:

14. Store responses in a dynamic cache

We're going to store these responses in a different cache, although this isn't strictly necessary. We'll call this one 'v1-dynamic'. Add this at the top of the 'sw.js' file:

We don't need to create this cache when the Worker installs because it only caches responses reactively – that is, after the browser has made the request. Instead we can do all the work in the fetch listener.

Let's add the network first logic inside our if (allow) statement.

This code opens the cache, makes the network request, caches the network's response and then returns the response to the page. 

Open up the app. Reload the page to get the latest version of the Worker. Now click through until you see the result page meaning a request has been made to the API.

Once that has happened check in DevTools again and you should see the two caches and the cached API response and index route in the dynamic cache.

15. Tell the Worker what to do when offline

So we've got our cached response, but if we go offline again you'll see that the app still fails to load.

Why is this? Well, we've not told the Worker what to do when the network request fails. We can correct this by adding a catch method to the end of the fetch(event.request) promise chain.

Now save and try this again in offline mode. Hopefully you'll now see the app working as if it were online! Pretty cool.

16. Manage user expectations

Right, so we've got a fully functioning offline-capable app – but it isn't going to magically work all the time. The data we get from the API is time-sensitive so we could end up in a situation where the cached response is served up but it is out of date and none of the data is relevant. It's worth noting that cached data doesn't expire automatically – it has to be manually removed or replaced – so we can't set an expiry date like we can with a cookie.

The question our app asks is 'Will it rain today?', yet we get five days' worth of data in the API's response so, in theory, the cached version will be valid for five days even though the forecast will become less accurate as times goes by. 

We should consider these two scenarios to manage the user's expectations:

  • User is offline and has been served an old, almost out-of-date cache.
  • User is offline and the cached data is out-of-date.

We can detect the user's network status in the page but on a mobile, non-WiFi connection it's possible that connection was lost momentarily just as the API request was being made. So rather than displaying a 'You are offline' message for a brief flicker it would be better to determine that the response received by the page is from the cache rather than the network.

Fortunately, because our data already contains date/time information, we can determine if the data is from the cache by checking if the first date is in the past. If this wasn't the case we'd probably be able to modify the body of the response in the Worker before caching it to include a timestamp.

17. Flag up old data

Time to open up the app's 'main.js' file. On line 172 you'll see that we are already creating an array called inDateItems that filters the full array so that it only contains forecast items for today's date. Then below this we check if the array has any items.

If it is empty we show an error message to the user informing them that the data is out-of-date, so this already covers one of the scenarios. But what about when the data is old but not fully out of date?

We could do this by checking the date of the first item in the array and comparing it to now to see if it exceeds a certain threshold. You can add these constants just inside the inDateItems.length check:

Now we have a Boolean to flag if our data is stale or not, but what should we do with it? Well, here's a little something we made earlier… add this below the lines you've just added:

This pre-prepared method will display a message to the user that tells them the data is stale – It's not easy to simulate stale data so call showStale() without the dataIsStale check to manually show the UI. In addition it provides a button which will allow them to refresh the data and a warning message if they are currently offline. When offline, the button is disabled. 

This is easily achieved by listening to the online and offline events that are emitted on the window, but we also need to check the initial state because the events are only emitted when the status changes. Our new Service Worker allows the page to be loaded even when there is no connection so we also can't assume we have a connection when the page renders. Check the code in 'main.js' to see how this is implemented.

18. Head to DevTools

VUnjurYvp8gj2fpGzmuodj.jpg

Once a request has been made to the API, check in DevTools again and you should see the two caches, with the cached API response and index route in the dynamic cache

Now this is when development starts to get tricky. We're making changes to files that are cached by our Service Worker, but because we're in dev mode the file names aren't revved. Changes to the Worker itself are automatically picked up and handled because we ticked the 'Update on reload' option in DevTools but the cached assets aren't reloaded because we're using a cache-first approach – meaning we don't get to see our changes to the app's code.

Once again DevTools comes to the rescue. Next to the 'Update on reload' option is an option called 'Bypass for network'. This slightly obscure name doesn't make it obvious (at least not to me!) what it actually does. But if you tick this option then all requests will come from the network, rather than the Service Worker. 

It doesn't disable the Worker entirely so you will still be able to install and activate the Worker but you can be sure that everything comes from the network.

19. Remove the stale cache

So we know we've got a stale response in the cache but how do we rectify this? In this scenario we don't really need to do anything because once the user has reconnected to the internet they can run the request again and the cache will be updated in the background – just one of the benefits of a network-first approach.

However, for the purposes of this tutorial, we wanted to demonstrate how you can clean up stale items in your cache.

As it stands the Worker is manipulating the cache but only the page is aware that the data is out of date. How can the page tell the Worker to update the cache? Well, we don't have to. The page can access the cache directly itself.

There is a click handler for the refresh data button ($btnStale) ready to be populated on line 395 (approx). 

Just as in the Worker, we need to open the cache using its name first. We named our API cache v1-dynamic so we have to use the same name here. Once open we can request that the cache deletes the item matching the request URL. Add the following inside the click handler to do the magic:

In production you'd need to check the browser has support for the cache API before implementing this.

20. Finish up

Done. In the first part of this tutorial, we reduced our subsequent load time from around 30 seconds to less than one second. Now we've made the app fully offline compatible. 

Hopefully, that'll give you a good grounding in how to set up a simple Service Worker and show you some of the things you need to be aware of. You can most definitely use this in production today. Good luck!

This article originally appeared in net magazine issues 311 and 312. Buy back issues or subscribe now.

Read more:

View the full article

Share this post


Link to post
Share on other sites

Join the conversation

You can post now and register later. If you have an account, sign in now to post with your account.

Guest
Reply to this topic...

×   Pasted as rich text.   Paste as plain text instead

  Only 75 emoji are allowed.

×   Your link has been automatically embedded.   Display as a link instead

×   Your previous content has been restored.   Clear editor

×   You cannot paste images directly. Upload or insert images from URL.

Sign in to follow this  

×